Tracking de l'application VApp (IHM du jeu)

This commit is contained in:
2025-05-11 18:04:12 +02:00
commit 89e9db9b62
17763 changed files with 3718499 additions and 0 deletions

View File

@ -0,0 +1,11 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('array-bracket-newline', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,11 @@
/**
* @author Toru Nagashima
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('array-bracket-spacing', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,11 @@
/**
* @author alshyra
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('array-element-newline', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,9 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('arrow-spacing')

View File

@ -0,0 +1,144 @@
/**
* @fileoverview Define a style for the props casing in templates.
* @author Armano
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
const svgAttributes = require('../utils/svg-attributes-weird-case.json')
/**
* @param {VDirective | VAttribute} node
* @returns {string | null}
*/
function getAttributeName(node) {
if (!node.directive) {
return node.key.rawName
}
if (
node.key.name.name === 'bind' &&
node.key.argument &&
node.key.argument.type === 'VIdentifier'
) {
return node.key.argument.rawName
}
return null
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'enforce attribute naming style on custom components in template',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/attribute-hyphenation.html'
},
fixable: 'code',
schema: [
{
enum: ['always', 'never']
},
{
type: 'object',
properties: {
ignore: {
type: 'array',
items: {
allOf: [
{ type: 'string' },
{ not: { type: 'string', pattern: ':exit$' } },
{ not: { type: 'string', pattern: '^\\s*$' } }
]
},
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
],
messages: {
mustBeHyphenated: "Attribute '{{text}}' must be hyphenated.",
cannotBeHyphenated: "Attribute '{{text}}' can't be hyphenated."
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const option = context.options[0]
const optionsPayload = context.options[1]
const useHyphenated = option !== 'never'
const ignoredAttributes = ['data-', 'aria-', 'slot-scope', ...svgAttributes]
if (optionsPayload && optionsPayload.ignore) {
ignoredAttributes.push(...optionsPayload.ignore)
}
const caseConverter = casing.getExactConverter(
useHyphenated ? 'kebab-case' : 'camelCase'
)
/**
* @param {VDirective | VAttribute} node
* @param {string} name
*/
function reportIssue(node, name) {
const text = sourceCode.getText(node.key)
context.report({
node: node.key,
loc: node.loc,
messageId: useHyphenated ? 'mustBeHyphenated' : 'cannotBeHyphenated',
data: {
text
},
fix: (fixer) => {
if (text.includes('_')) {
return null
}
if (/^[A-Z]/.test(name)) {
return null
}
return fixer.replaceText(
node.key,
text.replace(name, caseConverter(name))
)
}
})
}
/**
* @param {string} value
*/
function isIgnoredAttribute(value) {
const isIgnored = ignoredAttributes.some((attr) => value.includes(attr))
if (isIgnored) {
return true
}
return useHyphenated ? value.toLowerCase() === value : !/-/.test(value)
}
return utils.defineTemplateBodyVisitor(context, {
VAttribute(node) {
if (
!utils.isCustomComponent(node.parent.parent) &&
node.parent.parent.name !== 'slot'
)
return
const name = getAttributeName(node)
if (name === null || isIgnoredAttribute(name)) return
reportIssue(node, name)
}
})
}
}

View File

@ -0,0 +1,463 @@
/**
* @fileoverview enforce ordering of attributes
* @author Erin Depew
*/
'use strict'
const utils = require('../utils')
/**
* @typedef { VDirective & { key: VDirectiveKey & { name: VIdentifier & { name: 'bind' } } } } VBindDirective
*/
const ATTRS = {
DEFINITION: 'DEFINITION',
LIST_RENDERING: 'LIST_RENDERING',
CONDITIONALS: 'CONDITIONALS',
RENDER_MODIFIERS: 'RENDER_MODIFIERS',
GLOBAL: 'GLOBAL',
UNIQUE: 'UNIQUE',
SLOT: 'SLOT',
TWO_WAY_BINDING: 'TWO_WAY_BINDING',
OTHER_DIRECTIVES: 'OTHER_DIRECTIVES',
OTHER_ATTR: 'OTHER_ATTR',
ATTR_STATIC: 'ATTR_STATIC',
ATTR_DYNAMIC: 'ATTR_DYNAMIC',
ATTR_SHORTHAND_BOOL: 'ATTR_SHORTHAND_BOOL',
EVENTS: 'EVENTS',
CONTENT: 'CONTENT'
}
/**
* Check whether the given attribute is `v-bind` directive.
* @param {VAttribute | VDirective | undefined | null} node
* @returns { node is VBindDirective }
*/
function isVBind(node) {
return Boolean(node && node.directive && node.key.name.name === 'bind')
}
/**
* Check whether the given attribute is `v-model` directive.
* @param {VAttribute | VDirective | undefined | null} node
* @returns { node is VDirective }
*/
function isVModel(node) {
return Boolean(node && node.directive && node.key.name.name === 'model')
}
/**
* Check whether the given attribute is plain attribute.
* @param {VAttribute | VDirective | undefined | null} node
* @returns { node is VAttribute }
*/
function isVAttribute(node) {
return Boolean(node && !node.directive)
}
/**
* Check whether the given attribute is plain attribute, `v-bind` directive or `v-model` directive.
* @param {VAttribute | VDirective | undefined | null} node
* @returns { node is VAttribute }
*/
function isVAttributeOrVBindOrVModel(node) {
return isVAttribute(node) || isVBind(node) || isVModel(node)
}
/**
* Check whether the given attribute is `v-bind="..."` directive.
* @param {VAttribute | VDirective | undefined | null} node
* @returns { node is VBindDirective }
*/
function isVBindObject(node) {
return isVBind(node) && node.key.argument == null
}
/**
* Check whether the given attribute is a shorthand boolean like `selected`.
* @param {VAttribute | VDirective | undefined | null} node
* @returns { node is VAttribute }
*/
function isVShorthandBoolean(node) {
return isVAttribute(node) && !node.value
}
/**
* @param {VAttribute | VDirective} attribute
* @param {SourceCode} sourceCode
*/
function getAttributeName(attribute, sourceCode) {
if (attribute.directive) {
if (isVBind(attribute)) {
return attribute.key.argument
? sourceCode.getText(attribute.key.argument)
: ''
} else {
return getDirectiveKeyName(attribute.key, sourceCode)
}
} else {
return attribute.key.name
}
}
/**
* @param {VDirectiveKey} directiveKey
* @param {SourceCode} sourceCode
*/
function getDirectiveKeyName(directiveKey, sourceCode) {
let text = `v-${directiveKey.name.name}`
if (directiveKey.argument) {
text += `:${sourceCode.getText(directiveKey.argument)}`
}
for (const modifier of directiveKey.modifiers) {
text += `.${modifier.name}`
}
return text
}
/**
* @param {VAttribute | VDirective} attribute
*/
function getAttributeType(attribute) {
let propName
if (attribute.directive) {
if (!isVBind(attribute)) {
const name = attribute.key.name.name
switch (name) {
case 'for': {
return ATTRS.LIST_RENDERING
}
case 'if':
case 'else-if':
case 'else':
case 'show':
case 'cloak': {
return ATTRS.CONDITIONALS
}
case 'pre':
case 'once': {
return ATTRS.RENDER_MODIFIERS
}
case 'model': {
return ATTRS.TWO_WAY_BINDING
}
case 'on': {
return ATTRS.EVENTS
}
case 'html':
case 'text': {
return ATTRS.CONTENT
}
case 'slot': {
return ATTRS.SLOT
}
case 'is': {
return ATTRS.DEFINITION
}
default: {
return ATTRS.OTHER_DIRECTIVES
}
}
}
propName =
attribute.key.argument && attribute.key.argument.type === 'VIdentifier'
? attribute.key.argument.rawName
: ''
} else {
propName = attribute.key.name
}
switch (propName) {
case 'is': {
return ATTRS.DEFINITION
}
case 'id': {
return ATTRS.GLOBAL
}
case 'ref':
case 'key': {
return ATTRS.UNIQUE
}
case 'slot':
case 'slot-scope': {
return ATTRS.SLOT
}
default: {
if (isVBind(attribute)) {
return ATTRS.ATTR_DYNAMIC
}
if (isVShorthandBoolean(attribute)) {
return ATTRS.ATTR_SHORTHAND_BOOL
}
return ATTRS.ATTR_STATIC
}
}
}
/**
* @param {VAttribute | VDirective} attribute
* @param { { [key: string]: number } } attributePosition
* @returns {number | null} If the value is null, the order is omitted. Do not force the order.
*/
function getPosition(attribute, attributePosition) {
const attributeType = getAttributeType(attribute)
return attributePosition[attributeType] == null
? null
: attributePosition[attributeType]
}
/**
* @param {VAttribute | VDirective} prevNode
* @param {VAttribute | VDirective} currNode
* @param {SourceCode} sourceCode
*/
function isAlphabetical(prevNode, currNode, sourceCode) {
const prevName = getAttributeName(prevNode, sourceCode)
const currName = getAttributeName(currNode, sourceCode)
if (prevName === currName) {
const prevIsBind = isVBind(prevNode)
const currIsBind = isVBind(currNode)
return prevIsBind <= currIsBind
}
return prevName < currName
}
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
function create(context) {
const sourceCode = context.getSourceCode()
const otherAttrs = [
ATTRS.ATTR_DYNAMIC,
ATTRS.ATTR_STATIC,
ATTRS.ATTR_SHORTHAND_BOOL
]
let attributeOrder = [
ATTRS.DEFINITION,
ATTRS.LIST_RENDERING,
ATTRS.CONDITIONALS,
ATTRS.RENDER_MODIFIERS,
ATTRS.GLOBAL,
[ATTRS.UNIQUE, ATTRS.SLOT],
ATTRS.TWO_WAY_BINDING,
ATTRS.OTHER_DIRECTIVES,
otherAttrs,
ATTRS.EVENTS,
ATTRS.CONTENT
]
if (context.options[0] && context.options[0].order) {
attributeOrder = [...context.options[0].order]
// check if `OTHER_ATTR` is valid
for (const item of attributeOrder.flat()) {
if (item === ATTRS.OTHER_ATTR) {
for (const attribute of attributeOrder.flat()) {
if (otherAttrs.includes(attribute)) {
throw new Error(
`Value "${ATTRS.OTHER_ATTR}" is not allowed with "${attribute}".`
)
}
}
}
}
// expand `OTHER_ATTR` alias
for (const [index, item] of attributeOrder.entries()) {
if (item === ATTRS.OTHER_ATTR) {
attributeOrder[index] = otherAttrs
} else if (Array.isArray(item) && item.includes(ATTRS.OTHER_ATTR)) {
const attributes = item.filter((i) => i !== ATTRS.OTHER_ATTR)
attributes.push(...otherAttrs)
attributeOrder[index] = attributes
}
}
}
const alphabetical = Boolean(
context.options[0] && context.options[0].alphabetical
)
/** @type { { [key: string]: number } } */
const attributePosition = {}
for (const [i, item] of attributeOrder.entries()) {
if (Array.isArray(item)) {
for (const attr of item) {
attributePosition[attr] = i
}
} else attributePosition[item] = i
}
/**
* @param {VAttribute | VDirective} node
* @param {VAttribute | VDirective} previousNode
*/
function reportIssue(node, previousNode) {
const currentNode = sourceCode.getText(node.key)
const prevNode = sourceCode.getText(previousNode.key)
context.report({
node,
messageId: 'expectedOrder',
data: {
currentNode,
prevNode
},
fix(fixer) {
const attributes = node.parent.attributes
/** @type { (node: VAttribute | VDirective | undefined) => boolean } */
let isMoveUp
if (isVBindObject(node)) {
// prev, v-bind:foo, v-bind -> v-bind:foo, v-bind, prev
isMoveUp = isVAttributeOrVBindOrVModel
} else if (isVAttributeOrVBindOrVModel(node)) {
// prev, v-bind, v-bind:foo -> v-bind, v-bind:foo, prev
isMoveUp = isVBindObject
} else {
isMoveUp = () => false
}
const previousNodes = attributes.slice(
attributes.indexOf(previousNode),
attributes.indexOf(node)
)
const moveNodes = [node]
for (const node of previousNodes) {
if (isMoveUp(node)) {
moveNodes.unshift(node)
} else {
moveNodes.push(node)
}
}
return moveNodes.map((moveNode, index) => {
const text = sourceCode.getText(moveNode)
return fixer.replaceText(previousNodes[index] || node, text)
})
}
})
}
return utils.defineTemplateBodyVisitor(context, {
VStartTag(node) {
const attributeAndPositions = getAttributeAndPositionList(node)
if (attributeAndPositions.length <= 1) {
return
}
let { attr: previousNode, position: previousPosition } =
attributeAndPositions[0]
for (let index = 1; index < attributeAndPositions.length; index++) {
const { attr, position } = attributeAndPositions[index]
let valid = previousPosition <= position
if (valid && alphabetical && previousPosition === position) {
valid = isAlphabetical(previousNode, attr, sourceCode)
}
if (valid) {
previousNode = attr
previousPosition = position
} else {
reportIssue(attr, previousNode)
}
}
}
})
/**
* @param {VStartTag} node
* @returns { { attr: ( VAttribute | VDirective ), position: number }[] }
*/
function getAttributeAndPositionList(node) {
const attributes = node.attributes.filter((node, index, attributes) => {
if (
isVBindObject(node) &&
(isVAttributeOrVBindOrVModel(attributes[index - 1]) ||
isVAttributeOrVBindOrVModel(attributes[index + 1]))
) {
// In Vue 3, ignore `v-bind="object"`, which is
// a pair of `v-bind:foo="..."` and `v-bind="object"` and
// a pair of `v-model="..."` and `v-bind="object"`,
// because changing the order behaves differently.
return false
}
return true
})
const results = []
for (const [index, attr] of attributes.entries()) {
const position = getPositionFromAttrIndex(index)
if (position == null) {
// The omitted order is skipped.
continue
}
results.push({ attr, position })
}
return results
/**
* @param {number} index
* @returns {number | null}
*/
function getPositionFromAttrIndex(index) {
const node = attributes[index]
if (isVBindObject(node)) {
// node is `v-bind ="object"` syntax
// In Vue 3, if change the order of `v-bind:foo="..."`, `v-model="..."` and `v-bind="object"`,
// the behavior will be different, so adjust so that there is no change in behavior.
const len = attributes.length
for (let nextIndex = index + 1; nextIndex < len; nextIndex++) {
const next = attributes[nextIndex]
if (isVAttributeOrVBindOrVModel(next) && !isVBindObject(next)) {
// It is considered to be in the same order as the next bind prop node.
return getPositionFromAttrIndex(nextIndex)
}
}
}
return getPosition(node, attributePosition)
}
}
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce order of attributes',
categories: ['vue3-recommended', 'recommended'],
url: 'https://eslint.vuejs.org/rules/attributes-order.html'
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
order: {
type: 'array',
items: {
anyOf: [
{ enum: Object.values(ATTRS) },
{
type: 'array',
items: {
enum: Object.values(ATTRS),
uniqueItems: true,
additionalItems: false
}
}
]
},
uniqueItems: true,
additionalItems: false
},
alphabetical: { type: 'boolean' }
},
additionalProperties: false
}
],
messages: {
expectedOrder: `Attribute "{{currentNode}}" should go before "{{prevNode}}".`
}
},
create
}

View File

@ -0,0 +1,227 @@
/**
* @fileoverview Disallow use other than available `lang`
* @author Yosuke Ota
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {object} BlockOptions
* @property {Set<string>} lang
* @property {boolean} allowNoLang
*/
/**
* @typedef { { [element: string]: BlockOptions | undefined } } Options
*/
/**
* @typedef {object} UserBlockOptions
* @property {string[] | string} [lang]
* @property {boolean} [allowNoLang]
*/
/**
* @typedef { { [element: string]: UserBlockOptions | undefined } } UserOptions
*/
/**
* https://vuejs.github.io/vetur/guide/highlighting.html
* <template lang="html"></template>
* <style lang="css"></style>
* <script lang="js"></script>
* <script lang="javascript"></script>
* @type {Record<string, string[] | undefined>}
*/
const DEFAULT_LANGUAGES = {
template: ['html'],
style: ['css'],
script: ['js', 'javascript']
}
/**
* @param {NonNullable<BlockOptions['lang']>} lang
*/
function getAllowsLangPhrase(lang) {
const langs = [...lang].map((s) => `"${s}"`)
switch (langs.length) {
case 1: {
return langs[0]
}
default: {
return `${langs.slice(0, -1).join(', ')}, and ${langs[langs.length - 1]}`
}
}
}
/**
* Normalizes a given option.
* @param {string} blockName The block name.
* @param {UserBlockOptions} option An option to parse.
* @returns {BlockOptions} Normalized option.
*/
function normalizeOption(blockName, option) {
/** @type {Set<string>} */
let lang
if (Array.isArray(option.lang)) {
lang = new Set(option.lang)
} else if (typeof option.lang === 'string') {
lang = new Set([option.lang])
} else {
lang = new Set()
}
let hasDefault = false
for (const def of DEFAULT_LANGUAGES[blockName] || []) {
if (lang.has(def)) {
lang.delete(def)
hasDefault = true
}
}
if (lang.size === 0) {
return {
lang,
allowNoLang: true
}
}
return {
lang,
allowNoLang: hasDefault || Boolean(option.allowNoLang)
}
}
/**
* Normalizes a given options.
* @param { UserOptions } options An option to parse.
* @returns {Options} Normalized option.
*/
function normalizeOptions(options) {
if (!options) {
return {}
}
/** @type {Options} */
const normalized = {}
for (const blockName of Object.keys(options)) {
const value = options[blockName]
if (value) {
normalized[blockName] = normalizeOption(blockName, value)
}
}
return normalized
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow use other than available `lang`',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/block-lang.html'
},
schema: [
{
type: 'object',
patternProperties: {
'^(?:\\S+)$': {
oneOf: [
{
type: 'object',
properties: {
lang: {
anyOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string'
},
uniqueItems: true,
additionalItems: false
}
]
},
allowNoLang: { type: 'boolean' }
},
additionalProperties: false
}
]
}
},
minProperties: 1,
additionalProperties: false
}
],
messages: {
expected:
"Only {{allows}} can be used for the 'lang' attribute of '<{{tag}}>'.",
missing: "The 'lang' attribute of '<{{tag}}>' is missing.",
unexpected: "Do not specify the 'lang' attribute of '<{{tag}}>'.",
useOrNot:
"Only {{allows}} can be used for the 'lang' attribute of '<{{tag}}>'. Or, not specifying the `lang` attribute is allowed.",
unexpectedDefault:
"Do not explicitly specify the default language for the 'lang' attribute of '<{{tag}}>'."
}
},
/** @param {RuleContext} context */
create(context) {
const options = normalizeOptions(
context.options[0] || {
script: { allowNoLang: true },
template: { allowNoLang: true },
style: { allowNoLang: true }
}
)
if (Object.keys(options).length === 0) {
return {}
}
/**
* @param {VElement} element
* @returns {void}
*/
function verify(element) {
const tag = element.name
const option = options[tag]
if (!option) {
return
}
const lang = utils.getAttribute(element, 'lang')
if (lang == null || lang.value == null) {
if (!option.allowNoLang) {
context.report({
node: element.startTag,
messageId: 'missing',
data: {
tag
}
})
}
return
}
if (!option.lang.has(lang.value.value)) {
let messageId
if (!option.allowNoLang) {
messageId = 'expected'
} else if (option.lang.size === 0) {
messageId = (DEFAULT_LANGUAGES[tag] || []).includes(lang.value.value)
? 'unexpectedDefault'
: 'unexpected'
} else {
messageId = 'useOrNot'
}
context.report({
node: lang,
messageId,
data: {
tag,
allows: getAllowsLangPhrase(option.lang)
}
})
}
}
return utils.defineDocumentVisitor(context, {
'VDocumentFragment > VElement': verify
})
}
}

View File

@ -0,0 +1,185 @@
/**
* @author Yosuke Ota
* issue https://github.com/vuejs/eslint-plugin-vue/issues/140
*/
'use strict'
const utils = require('../utils')
const { parseSelector } = require('../utils/selector')
/**
* @typedef {import('../utils/selector').VElementSelector} VElementSelector
*/
const DEFAULT_ORDER = Object.freeze([['script', 'template'], 'style'])
/**
* @param {VElement} element
* @return {string}
*/
function getAttributeString(element) {
return element.startTag.attributes
.map((attribute) => {
if (attribute.value && attribute.value.type !== 'VLiteral') {
return ''
}
return `${attribute.key.name}${
attribute.value && attribute.value.value
? `=${attribute.value.value}`
: ''
}`
})
.join(' ')
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce order of component top-level elements',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/block-order.html'
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
order: {
type: 'array',
items: {
anyOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' }, uniqueItems: true }
]
},
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
],
messages: {
unexpected:
"'<{{elementName}}{{elementAttributes}}>' should be above '<{{firstUnorderedName}}{{firstUnorderedAttributes}}>' on line {{line}}."
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
/**
* @typedef {object} OrderElement
* @property {string} selectorText
* @property {VElementSelector} selector
* @property {number} index
*/
/** @type {OrderElement[]} */
const orders = []
/** @type {(string|string[])[]} */
const orderOptions =
(context.options[0] && context.options[0].order) || DEFAULT_ORDER
for (const [index, selectorOrSelectors] of orderOptions.entries()) {
if (Array.isArray(selectorOrSelectors)) {
for (const selector of selectorOrSelectors) {
orders.push({
selectorText: selector,
selector: parseSelector(selector, context),
index
})
}
} else {
orders.push({
selectorText: selectorOrSelectors,
selector: parseSelector(selectorOrSelectors, context),
index
})
}
}
/**
* @param {VElement} element
*/
function getOrderElement(element) {
return orders.find((o) => o.selector.test(element))
}
const sourceCode = context.getSourceCode()
const documentFragment =
sourceCode.parserServices.getDocumentFragment &&
sourceCode.parserServices.getDocumentFragment()
function getTopLevelHTMLElements() {
if (documentFragment) {
return documentFragment.children.filter(utils.isVElement)
}
return []
}
return {
Program(node) {
if (utils.hasInvalidEOF(node)) {
return
}
const elements = getTopLevelHTMLElements()
const elementsWithOrder = elements.flatMap((element) => {
const order = getOrderElement(element)
return order ? [{ order, element }] : []
})
const sourceCode = context.getSourceCode()
for (const [index, elementWithOrders] of elementsWithOrder.entries()) {
const { order: expected, element } = elementWithOrders
const firstUnordered = elementsWithOrder
.slice(0, index)
.filter(({ order }) => expected.index < order.index)
.sort((e1, e2) => e1.order.index - e2.order.index)[0]
if (firstUnordered) {
const firstUnorderedAttributes = getAttributeString(
firstUnordered.element
)
const elementAttributes = getAttributeString(element)
context.report({
node: element,
loc: element.loc,
messageId: 'unexpected',
data: {
elementName: element.name,
elementAttributes: elementAttributes
? ` ${elementAttributes}`
: '',
firstUnorderedName: firstUnordered.element.name,
firstUnorderedAttributes: firstUnorderedAttributes
? ` ${firstUnorderedAttributes}`
: '',
line: firstUnordered.element.loc.start.line
},
*fix(fixer) {
// insert element before firstUnordered
const fixedElements = elements.flatMap((it) => {
if (it === firstUnordered.element) {
return [element, it]
} else if (it === element) {
return []
}
return [it]
})
for (let i = elements.length - 1; i >= 0; i--) {
if (elements[i] !== fixedElements[i]) {
yield fixer.replaceTextRange(
elements[i].range,
sourceCode.text.slice(...fixedElements[i].range)
)
}
}
}
})
}
}
}
}
}
}

View File

@ -0,0 +1,11 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('block-spacing', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,364 @@
/**
* @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`
}
}
}
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}}>'.",
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 sourceCode = context.getSourceCode()
const df =
sourceCode.parserServices.getDocumentFragment &&
sourceCode.parserServices.getDocumentFragment()
if (!df) {
return {}
}
/**
* @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 &&
/[\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) {
normalizeOptionValue({
singleline: elementsOptions.singleline || options.singleline,
multiline: elementsOptions.multiline || options.multiline,
maxEmptyLines:
elementsOptions.maxEmptyLines == null
? options.maxEmptyLines
: elementsOptions.maxEmptyLines
})(element)
} else {
verifyElement(element, options)
}
}
}
const documentFragment = df
const verify = normalizeOptionValue(context.options[0])
return utils.defineTemplateBodyVisitor(
context,
{},
{
/** @param {Program} node */
Program(node) {
if (utils.hasInvalidEOF(node)) {
return
}
for (const element of documentFragment.children) {
if (utils.isVElement(element)) {
verify(element)
}
}
}
}
)
}
}

View File

@ -0,0 +1,11 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('brace-style', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,9 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapCoreRule('camelcase')

View File

@ -0,0 +1,9 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('comma-dangle')

View File

@ -0,0 +1,13 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('comma-spacing', {
skipDynamicArguments: true,
skipDynamicArgumentsReport: true,
applyDocument: true
})

View File

@ -0,0 +1,20 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('comma-style', {
create(_context, { baseHandlers }) {
return {
VSlotScopeExpression(node) {
if (baseHandlers.FunctionExpression) {
// @ts-expect-error -- Process params of VSlotScopeExpression as FunctionExpression.
baseHandlers.FunctionExpression(node)
}
}
}
}
})

View File

@ -0,0 +1,356 @@
/**
* @author Toru Nagashima <https://github.com/mysticatea>
*/
/* eslint-disable eslint-plugin/report-message-format */
'use strict'
const utils = require('../utils')
/**
* @typedef {object} RuleAndLocation
* @property {string} RuleAndLocation.ruleId
* @property {number} RuleAndLocation.index
* @property {string} [RuleAndLocation.key]
*/
const COMMENT_DIRECTIVE_B = /^\s*(eslint-(?:en|dis)able)(?:\s+|$)/
const COMMENT_DIRECTIVE_L = /^\s*(eslint-disable(?:-next)?-line)(?:\s+|$)/
/**
* Remove the ignored part from a given directive comment and trim it.
* @param {string} value The comment text to strip.
* @returns {string} The stripped text.
*/
function stripDirectiveComment(value) {
return value.split(/\s-{2,}\s/u)[0]
}
/**
* Parse a given comment.
* @param {RegExp} pattern The RegExp pattern to parse.
* @param {string} comment The comment value to parse.
* @returns {({type:string,rules:RuleAndLocation[]})|null} The parsing result.
*/
function parse(pattern, comment) {
const text = stripDirectiveComment(comment)
const match = pattern.exec(text)
if (match == null) {
return null
}
const type = match[1]
/** @type {RuleAndLocation[]} */
const rules = []
const rulesRe = /([^\s,]+)[\s,]*/g
let startIndex = match[0].length
rulesRe.lastIndex = startIndex
let res
while ((res = rulesRe.exec(text))) {
const ruleId = res[1].trim()
rules.push({
ruleId,
index: startIndex
})
startIndex = rulesRe.lastIndex
}
return { type, rules }
}
/**
* Enable rules.
* @param {RuleContext} context The rule context.
* @param {{line:number,column:number}} loc The location information to enable.
* @param { 'block' | 'line' } group The group to enable.
* @param {string | null} rule The rule ID to enable.
* @returns {void}
*/
function enable(context, loc, group, rule) {
if (rule) {
context.report({
loc,
messageId: group === 'block' ? 'enableBlockRule' : 'enableLineRule',
data: { rule }
})
} else {
context.report({
loc,
messageId: group === 'block' ? 'enableBlock' : 'enableLine'
})
}
}
/**
* Disable rules.
* @param {RuleContext} context The rule context.
* @param {{line:number,column:number}} loc The location information to disable.
* @param { 'block' | 'line' } group The group to disable.
* @param {string | null} rule The rule ID to disable.
* @param {string} key The disable directive key.
* @returns {void}
*/
function disable(context, loc, group, rule, key) {
if (rule) {
context.report({
loc,
messageId: group === 'block' ? 'disableBlockRule' : 'disableLineRule',
data: { rule, key }
})
} else {
context.report({
loc,
messageId: group === 'block' ? 'disableBlock' : 'disableLine',
data: { key }
})
}
}
/**
* Process a given comment token.
* If the comment is `eslint-disable` or `eslint-enable` then it reports the comment.
* @param {RuleContext} context The rule context.
* @param {Token} comment The comment token to process.
* @param {boolean} reportUnusedDisableDirectives To report unused eslint-disable comments.
* @returns {void}
*/
function processBlock(context, comment, reportUnusedDisableDirectives) {
const parsed = parse(COMMENT_DIRECTIVE_B, comment.value)
if (parsed === null) return
if (parsed.type === 'eslint-disable') {
if (parsed.rules.length > 0) {
const rules = reportUnusedDisableDirectives
? reportUnusedRules(context, comment, parsed.type, parsed.rules)
: parsed.rules
for (const rule of rules) {
disable(
context,
comment.loc.start,
'block',
rule.ruleId,
rule.key || '*'
)
}
} else {
const key = reportUnusedDisableDirectives
? reportUnused(context, comment, parsed.type)
: ''
disable(context, comment.loc.start, 'block', null, key)
}
} else {
if (parsed.rules.length > 0) {
for (const rule of parsed.rules) {
enable(context, comment.loc.start, 'block', rule.ruleId)
}
} else {
enable(context, comment.loc.start, 'block', null)
}
}
}
/**
* Process a given comment token.
* If the comment is `eslint-disable-line` or `eslint-disable-next-line` then it reports the comment.
* @param {RuleContext} context The rule context.
* @param {Token} comment The comment token to process.
* @param {boolean} reportUnusedDisableDirectives To report unused eslint-disable comments.
* @returns {void}
*/
function processLine(context, comment, reportUnusedDisableDirectives) {
const parsed = parse(COMMENT_DIRECTIVE_L, comment.value)
if (parsed != null && comment.loc.start.line === comment.loc.end.line) {
const line =
comment.loc.start.line + (parsed.type === 'eslint-disable-line' ? 0 : 1)
const column = -1
if (parsed.rules.length > 0) {
const rules = reportUnusedDisableDirectives
? reportUnusedRules(context, comment, parsed.type, parsed.rules)
: parsed.rules
for (const rule of rules) {
disable(context, { line, column }, 'line', rule.ruleId, rule.key || '')
enable(context, { line: line + 1, column }, 'line', rule.ruleId)
}
} else {
const key = reportUnusedDisableDirectives
? reportUnused(context, comment, parsed.type)
: ''
disable(context, { line, column }, 'line', null, key)
enable(context, { line: line + 1, column }, 'line', null)
}
}
}
/**
* Reports unused disable directive.
* Do not check the use of directives here. Filter the directives used with postprocess.
* @param {RuleContext} context The rule context.
* @param {Token} comment The comment token to report.
* @param {string} kind The comment directive kind.
* @returns {string} The report key
*/
function reportUnused(context, comment, kind) {
const loc = comment.loc
context.report({
loc,
messageId: 'unused',
data: { kind }
})
return locToKey(loc.start)
}
/**
* Reports unused disable directive rules.
* Do not check the use of directives here. Filter the directives used with postprocess.
* @param {RuleContext} context The rule context.
* @param {Token} comment The comment token to report.
* @param {string} kind The comment directive kind.
* @param {RuleAndLocation[]} rules To report rule.
* @returns { { ruleId: string, key: string }[] }
*/
function reportUnusedRules(context, comment, kind, rules) {
const sourceCode = context.getSourceCode()
const commentStart = comment.range[0] + 4 /* <!-- */
return rules.map((rule) => {
const start = sourceCode.getLocFromIndex(commentStart + rule.index)
const end = sourceCode.getLocFromIndex(
commentStart + rule.index + rule.ruleId.length
)
context.report({
loc: { start, end },
messageId: 'unusedRule',
data: { rule: rule.ruleId, kind }
})
return {
ruleId: rule.ruleId,
key: locToKey(start)
}
})
}
/**
* Gets the key of location
* @param {Position} location The location
* @returns {string} The key
*/
function locToKey(location) {
return `line:${location.line},column${location.column}`
}
/**
* Extracts the top-level elements in document fragment.
* @param {VDocumentFragment} documentFragment The document fragment.
* @returns {VElement[]} The top-level elements
*/
function extractTopLevelHTMLElements(documentFragment) {
return documentFragment.children.filter(utils.isVElement)
}
/**
* Extracts the top-level comments in document fragment.
* @param {VDocumentFragment} documentFragment The document fragment.
* @returns {Token[]} The top-level comments
*/
function extractTopLevelDocumentFragmentComments(documentFragment) {
const elements = extractTopLevelHTMLElements(documentFragment)
return documentFragment.comments.filter((comment) =>
elements.every(
(element) =>
comment.range[1] <= element.range[0] ||
element.range[1] <= comment.range[0]
)
)
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'support comment-directives in `<template>`', // eslint-disable-line eslint-plugin/require-meta-docs-description
categories: ['base'],
url: 'https://eslint.vuejs.org/rules/comment-directive.html'
},
schema: [
{
type: 'object',
properties: {
reportUnusedDisableDirectives: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
disableBlock: '--block {{key}}',
enableBlock: '++block',
disableLine: '--line {{key}}',
enableLine: '++line',
disableBlockRule: '-block {{rule}} {{key}}',
enableBlockRule: '+block {{rule}}',
disableLineRule: '-line {{rule}} {{key}}',
enableLineRule: '+line {{rule}}',
clear: 'clear',
unused: 'Unused {{kind}} directive (no problems were reported).',
unusedRule:
"Unused {{kind}} directive (no problems were reported from '{{rule}}')."
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
const options = context.options[0] || {}
/** @type {boolean} */
const reportUnusedDisableDirectives = options.reportUnusedDisableDirectives
const sourceCode = context.getSourceCode()
const documentFragment =
sourceCode.parserServices.getDocumentFragment &&
sourceCode.parserServices.getDocumentFragment()
return {
Program(node) {
if (node.templateBody) {
// Send directives to the post-process.
for (const comment of node.templateBody.comments) {
processBlock(context, comment, reportUnusedDisableDirectives)
processLine(context, comment, reportUnusedDisableDirectives)
}
// Send a clear mark to the post-process.
context.report({
loc: node.templateBody.loc.end,
messageId: 'clear'
})
}
if (documentFragment) {
// Send directives to the post-process.
for (const comment of extractTopLevelDocumentFragmentComments(
documentFragment
)) {
processBlock(context, comment, reportUnusedDisableDirectives)
processLine(context, comment, reportUnusedDisableDirectives)
}
// Send a clear mark to the post-process.
for (const element of extractTopLevelHTMLElements(documentFragment)) {
context.report({
loc: element.loc.end,
messageId: 'clear'
})
}
}
}
}
}
}

View File

@ -0,0 +1,308 @@
/**
* @author Yosuke Ota <https://github.com/ota-meshi>
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @typedef { 'script-setup' | 'composition' | 'composition-vue2' | 'options' } PreferOption
*
* @typedef {PreferOption[]} UserPreferOption
*
* @typedef {object} NormalizeOptions
* @property {object} allowsSFC
* @property {boolean} [allowsSFC.scriptSetup]
* @property {boolean} [allowsSFC.composition]
* @property {boolean} [allowsSFC.compositionVue2]
* @property {boolean} [allowsSFC.options]
* @property {object} allowsOther
* @property {boolean} [allowsOther.composition]
* @property {boolean} [allowsOther.compositionVue2]
* @property {boolean} [allowsOther.options]
*/
/** @type {PreferOption[]} */
const STYLE_OPTIONS = [
'script-setup',
'composition',
'composition-vue2',
'options'
]
/**
* Normalize options.
* @param {any[]} options The options user configured.
* @returns {NormalizeOptions} The normalized options.
*/
function parseOptions(options) {
/** @type {NormalizeOptions} */
const opts = { allowsSFC: {}, allowsOther: {} }
/** @type {UserPreferOption} */
const preferOptions = options[0] || ['script-setup', 'composition']
for (const prefer of preferOptions) {
switch (prefer) {
case 'script-setup': {
opts.allowsSFC.scriptSetup = true
break
}
case 'composition': {
opts.allowsSFC.composition = true
opts.allowsOther.composition = true
break
}
case 'composition-vue2': {
opts.allowsSFC.compositionVue2 = true
opts.allowsOther.compositionVue2 = true
break
}
case 'options': {
opts.allowsSFC.options = true
opts.allowsOther.options = true
break
}
}
}
if (
!opts.allowsOther.composition &&
!opts.allowsOther.compositionVue2 &&
!opts.allowsOther.options
) {
opts.allowsOther.composition = true
opts.allowsOther.compositionVue2 = true
opts.allowsOther.options = true
}
return opts
}
const OPTIONS_API_OPTIONS = new Set([
'mixins',
'extends',
// state
'data',
'computed',
'methods',
'watch',
'provide',
'inject',
// lifecycle
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'activated',
'deactivated',
'beforeDestroy',
'beforeUnmount',
'destroyed',
'unmounted',
'render',
'renderTracked',
'renderTriggered',
'errorCaptured',
// public API
'expose'
])
const COMPOSITION_API_OPTIONS = new Set(['setup'])
const COMPOSITION_API_VUE2_OPTIONS = new Set([
'setup',
'render', // https://github.com/vuejs/composition-api#template-refs
'renderTracked', // https://github.com/vuejs/composition-api#missing-apis
'renderTriggered' // https://github.com/vuejs/composition-api#missing-apis
])
const LIFECYCLE_HOOK_OPTIONS = new Set([
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'activated',
'deactivated',
'beforeDestroy',
'beforeUnmount',
'destroyed',
'unmounted',
'renderTracked',
'renderTriggered',
'errorCaptured'
])
/**
* @typedef { 'script-setup' | 'composition' | 'options' } ApiStyle
*/
/**
* @param {object} allowsOpt
* @param {boolean} [allowsOpt.scriptSetup]
* @param {boolean} [allowsOpt.composition]
* @param {boolean} [allowsOpt.compositionVue2]
* @param {boolean} [allowsOpt.options]
*/
function buildAllowedPhrase(allowsOpt) {
const phrases = []
if (allowsOpt.scriptSetup) {
phrases.push('`<script setup>`')
}
if (allowsOpt.composition) {
phrases.push('Composition API')
}
if (allowsOpt.compositionVue2) {
phrases.push('Composition API (Vue 2)')
}
if (allowsOpt.options) {
phrases.push('Options API')
}
return phrases.length > 2
? `${phrases.slice(0, -1).join(', ')} or ${phrases.slice(-1)[0]}`
: phrases.join(' or ')
}
/**
* @param {object} allowsOpt
* @param {boolean} [allowsOpt.scriptSetup]
* @param {boolean} [allowsOpt.composition]
* @param {boolean} [allowsOpt.compositionVue2]
* @param {boolean} [allowsOpt.options]
*/
function isPreferScriptSetup(allowsOpt) {
if (
!allowsOpt.scriptSetup ||
allowsOpt.composition ||
allowsOpt.compositionVue2 ||
allowsOpt.options
) {
return false
}
return true
}
/**
* @param {string} name
*/
function buildOptionPhrase(name) {
if (LIFECYCLE_HOOK_OPTIONS.has(name)) return `\`${name}\` lifecycle hook`
return name === 'setup' || name === 'render'
? `\`${name}\` function`
: `\`${name}\` option`
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce component API style',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/component-api-style.html'
},
fixable: null,
schema: [
{
type: 'array',
items: {
enum: STYLE_OPTIONS,
uniqueItems: true,
additionalItems: false
},
minItems: 1
}
],
messages: {
disallowScriptSetup:
'`<script setup>` is not allowed in your project. Use {{allowedApis}} instead.',
disallowComponentOption:
'{{disallowedApi}} is not allowed in your project. {{optionPhrase}} is part of the {{disallowedApi}}. Use {{allowedApis}} instead.',
disallowComponentOptionPreferScriptSetup:
'{{disallowedApi}} is not allowed in your project. Use `<script setup>` instead.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = parseOptions(context.options)
return utils.compositingVisitors(
{
Program() {
if (options.allowsSFC.scriptSetup) {
return
}
const scriptSetup = utils.getScriptSetupElement(context)
if (scriptSetup) {
context.report({
node: scriptSetup.startTag,
messageId: 'disallowScriptSetup',
data: {
allowedApis: buildAllowedPhrase(options.allowsSFC)
}
})
}
}
},
utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
const allows = utils.isSFCObject(context, node)
? options.allowsSFC
: options.allowsOther
if (
(allows.composition || allows.compositionVue2) &&
allows.options
) {
return
}
const apis = [
{
allow: allows.composition,
options: COMPOSITION_API_OPTIONS,
apiName: 'Composition API'
},
{
allow: allows.options,
options: OPTIONS_API_OPTIONS,
apiName: 'Options API'
},
{
allow: allows.compositionVue2,
options: COMPOSITION_API_VUE2_OPTIONS,
apiName: 'Composition API (Vue 2)'
}
]
for (const prop of node.properties) {
if (prop.type !== 'Property') {
continue
}
const name = utils.getStaticPropertyName(prop)
if (!name) {
continue
}
const disallowApi =
!apis.some((api) => api.allow && api.options.has(name)) &&
apis.find((api) => !api.allow && api.options.has(name))
if (disallowApi) {
context.report({
node: prop.key,
messageId: isPreferScriptSetup(allows)
? 'disallowComponentOptionPreferScriptSetup'
: 'disallowComponentOption',
data: {
disallowedApi: disallowApi.apiName,
optionPhrase: buildOptionPhrase(name),
allowedApis: buildAllowedPhrase(allows)
}
})
}
}
}
})
)
}
}

View File

@ -0,0 +1,113 @@
/**
* @fileoverview enforce specific casing for component definition name
* @author Armano
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
const allowedCaseOptions = ['PascalCase', 'kebab-case']
/**
* @param {Expression | SpreadElement} node
* @returns {node is (Literal | TemplateLiteral)}
*/
function canConvert(node) {
return (
node.type === 'Literal' ||
(node.type === 'TemplateLiteral' &&
node.expressions.length === 0 &&
node.quasis.length === 1)
)
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce specific casing for component definition name',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/component-definition-name-casing.html'
},
fixable: 'code',
schema: [
{
enum: allowedCaseOptions
}
],
messages: {
incorrectCase: 'Property name "{{value}}" is not {{caseType}}.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = context.options[0]
const caseType = allowedCaseOptions.includes(options)
? options
: 'PascalCase'
/**
* @param {Literal | TemplateLiteral} node
*/
function convertName(node) {
/** @type {string} */
let nodeValue
/** @type {Range} */
let range
if (node.type === 'TemplateLiteral') {
const quasis = node.quasis[0]
nodeValue = quasis.value.cooked
range = quasis.range
} else {
nodeValue = `${node.value}`
range = node.range
}
if (!casing.getChecker(caseType)(nodeValue)) {
context.report({
node,
messageId: 'incorrectCase',
data: {
value: nodeValue,
caseType
},
fix: (fixer) =>
fixer.replaceTextRange(
[range[0] + 1, range[1] - 1],
casing.getExactConverter(caseType)(nodeValue)
)
})
}
}
return utils.compositingVisitors(
utils.executeOnCallVueComponent(context, (node) => {
if (node.arguments.length === 2) {
const argument = node.arguments[0]
if (canConvert(argument)) {
convertName(argument)
}
}
}),
utils.executeOnVue(context, (obj) => {
const node = utils.findProperty(obj, 'name')
if (!node) return
if (!canConvert(node.value)) return
convertName(node.value)
}),
utils.defineScriptSetupVisitor(context, {
onDefineOptionsEnter(node) {
if (node.arguments.length === 0) return
const define = node.arguments[0]
if (define.type !== 'ObjectExpression') return
const nameNode = utils.findProperty(define, 'name')
if (!nameNode) return
if (!canConvert(nameNode.value)) return
convertName(nameNode.value)
}
})
)
}
}

View File

@ -0,0 +1,195 @@
/**
* @author Yosuke Ota
* issue https://github.com/vuejs/eslint-plugin-vue/issues/250
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
const { toRegExp } = require('../utils/regexp')
const allowedCaseOptions = ['PascalCase', 'kebab-case']
const defaultCase = 'PascalCase'
/**
* Checks whether the given variable is the type-only import object.
* @param {Variable} variable
* @returns {boolean} `true` if the given variable is the type-only import.
*/
function isTypeOnlyImport(variable) {
if (variable.defs.length === 0) return false
return variable.defs.every((def) => {
if (def.type !== 'ImportBinding') {
return false
}
if (def.parent.importKind === 'type') {
// check for `import type Foo from './xxx'`
return true
}
if (def.node.type === 'ImportSpecifier' && def.node.importKind === 'type') {
// check for `import { type Foo } from './xxx'`
return true
}
return false
})
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'enforce specific casing for the component naming style in template',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/component-name-in-template-casing.html'
},
fixable: 'code',
schema: [
{
enum: allowedCaseOptions
},
{
type: 'object',
properties: {
globals: {
type: 'array',
items: { type: 'string' },
uniqueItems: true
},
ignores: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
additionalItems: false
},
registeredComponentsOnly: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
incorrectCase: 'Component name "{{name}}" is not {{caseType}}.'
}
},
/** @param {RuleContext} context */
create(context) {
const caseOption = context.options[0]
const options = context.options[1] || {}
const caseType = allowedCaseOptions.includes(caseOption)
? caseOption
: defaultCase
/** @type {RegExp[]} */
const ignores = (options.ignores || []).map(toRegExp)
/** @type {string[]} */
const globals = (options.globals || []).map(casing.pascalCase)
const registeredComponentsOnly = options.registeredComponentsOnly !== false
const sourceCode = context.getSourceCode()
const tokens =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
/** @type { Set<string> } */
const registeredComponents = new Set(globals)
if (utils.isScriptSetup(context)) {
// For <script setup>
const globalScope = context.getSourceCode().scopeManager.globalScope
if (globalScope) {
// Only check find the import module
const moduleScope = globalScope.childScopes.find(
(scope) => scope.type === 'module'
)
for (const variable of (moduleScope && moduleScope.variables) || []) {
if (!isTypeOnlyImport(variable)) {
registeredComponents.add(variable.name)
}
}
}
}
/**
* Checks whether the given node is the verification target node.
* @param {VElement} node element node
* @returns {boolean} `true` if the given node is the verification target node.
*/
function isVerifyTarget(node) {
if (ignores.some((re) => re.test(node.rawName))) {
// ignore
return false
}
if (
(!utils.isHtmlElementNode(node) && !utils.isSvgElementNode(node)) ||
utils.isHtmlWellKnownElementName(node.rawName) ||
utils.isSvgWellKnownElementName(node.rawName) ||
utils.isVueBuiltInElementName(node.rawName)
) {
return false
}
if (!registeredComponentsOnly) {
// If the user specifies registeredComponentsOnly as false, it checks all component tags.
return true
}
// We only verify the registered components.
return registeredComponents.has(casing.pascalCase(node.rawName))
}
let hasInvalidEOF = false
return utils.defineTemplateBodyVisitor(
context,
{
VElement(node) {
if (hasInvalidEOF) {
return
}
if (!isVerifyTarget(node)) {
return
}
const name = node.rawName
if (!casing.getChecker(caseType)(name)) {
const startTag = node.startTag
const open = tokens.getFirstToken(startTag)
const casingName = casing.getExactConverter(caseType)(name)
context.report({
node: open,
loc: open.loc,
messageId: 'incorrectCase',
data: {
name,
caseType
},
*fix(fixer) {
yield fixer.replaceText(open, `<${casingName}`)
const endTag = node.endTag
if (endTag) {
const endTagOpen = tokens.getFirstToken(endTag)
yield fixer.replaceText(endTagOpen, `</${casingName}`)
}
}
})
}
}
},
{
Program(node) {
hasInvalidEOF = utils.hasInvalidEOF(node)
},
...(registeredComponentsOnly
? utils.executeOnVue(context, (obj) => {
for (const n of utils.getRegisteredComponents(obj)) {
registeredComponents.add(n.name)
}
})
: {})
}
)
}
}

View File

@ -0,0 +1,103 @@
/**
* @author Pig Fang
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
/**
* @param {import('../../typings/eslint-plugin-vue/util-types/ast').Expression} node
* @returns {string | null}
*/
function getOptionsComponentName(node) {
if (node.type === 'Identifier') {
return node.name
}
if (node.type === 'Literal') {
return typeof node.value === 'string' ? node.value : null
}
return null
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'enforce the casing of component name in `components` options',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/component-options-name-casing.html'
},
fixable: 'code',
hasSuggestions: true,
schema: [{ enum: casing.allowedCaseOptions }],
messages: {
caseNotMatched: 'Component name "{{component}}" is not {{caseType}}.',
possibleRenaming: 'Rename component name to be in {{caseType}}.'
}
},
/** @param {RuleContext} context */
create(context) {
const caseType = context.options[0] || 'PascalCase'
const canAutoFix = caseType === 'PascalCase'
const checkCase = casing.getChecker(caseType)
const convert = casing.getConverter(caseType)
return utils.executeOnVue(context, (obj) => {
const node = utils.findProperty(obj, 'components')
if (!node || node.value.type !== 'ObjectExpression') {
return
}
for (const property of node.value.properties) {
if (property.type !== 'Property') {
continue
}
const name = getOptionsComponentName(property.key)
if (!name || checkCase(name)) {
continue
}
context.report({
node: property.key,
messageId: 'caseNotMatched',
data: {
component: name,
caseType
},
fix: canAutoFix
? (fixer) => {
const converted = convert(name)
return property.shorthand
? fixer.replaceText(property, `${converted}: ${name}`)
: fixer.replaceText(property.key, converted)
}
: undefined,
suggest: canAutoFix
? undefined
: [
{
messageId: 'possibleRenaming',
data: { caseType },
fix: (fixer) => {
const converted = convert(name)
if (caseType === 'kebab-case') {
return property.shorthand
? fixer.replaceText(property, `'${converted}': ${name}`)
: fixer.replaceText(property.key, `'${converted}'`)
}
return property.shorthand
? fixer.replaceText(property, `${converted}: ${name}`)
: fixer.replaceText(property.key, converted)
}
}
]
})
}
})
}
}

View File

@ -0,0 +1,23 @@
'use strict'
const baseRule = require('./block-order')
module.exports = {
// eslint-disable-next-line eslint-plugin/require-meta-schema, eslint-plugin/prefer-message-ids -- inherit schema from base rule
meta: {
...baseRule.meta,
// eslint-disable-next-line eslint-plugin/meta-property-ordering
type: baseRule.meta.type,
docs: {
description: baseRule.meta.docs.description,
categories: ['vue3-recommended', 'recommended'],
url: 'https://eslint.vuejs.org/rules/component-tags-order.html'
},
deprecated: true,
replacedBy: ['block-order']
},
/** @param {RuleContext} context */
create(context) {
return baseRule.create(context)
}
}

View File

@ -0,0 +1,308 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const { findVariable } = require('@eslint-community/eslint-utils')
const utils = require('../utils')
const casing = require('../utils/casing')
const { toRegExp } = require('../utils/regexp')
/**
* @typedef {import('../utils').VueObjectData} VueObjectData
*/
const ALLOWED_CASE_OPTIONS = ['kebab-case', 'camelCase']
const DEFAULT_CASE = 'camelCase'
/**
* @typedef {object} NameWithLoc
* @property {string} name
* @property {SourceLocation} loc
*/
/**
* Get the name param node from the given CallExpression
* @param {CallExpression} node CallExpression
* @returns { NameWithLoc | null }
*/
function getNameParamNode(node) {
const nameLiteralNode = node.arguments[0]
if (nameLiteralNode && utils.isStringLiteral(nameLiteralNode)) {
const name = utils.getStringLiteralValue(nameLiteralNode)
if (name != null) {
return { name, loc: nameLiteralNode.loc }
}
}
// cannot check
return null
}
/**
* Get the callee member node from the given CallExpression
* @param {CallExpression} node CallExpression
*/
function getCalleeMemberNode(node) {
const callee = utils.skipChainExpression(node.callee)
if (callee.type === 'MemberExpression') {
const name = utils.getStaticPropertyName(callee)
if (name) {
return { name, member: callee }
}
}
return null
}
const OBJECT_OPTION_SCHEMA = {
type: 'object',
properties: {
ignores: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce specific casing for custom event name',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/custom-event-name-casing.html'
},
fixable: null,
schema: {
anyOf: [
{
type: 'array',
items: [
{
enum: ALLOWED_CASE_OPTIONS
},
OBJECT_OPTION_SCHEMA
]
},
// For backward compatibility
{
type: 'array',
items: [OBJECT_OPTION_SCHEMA]
}
]
},
messages: {
unexpected: "Custom event name '{{name}}' must be {{caseType}}."
}
},
/** @param {RuleContext} context */
create(context) {
/** @type {Map<ObjectExpression|Program, {contextReferenceIds:Set<Identifier>,emitReferenceIds:Set<Identifier>}>} */
const setupContexts = new Map()
const options =
context.options.length === 1 && typeof context.options[0] !== 'string'
? // For backward compatibility
[undefined, context.options[0]]
: context.options
const caseType = options[0] || DEFAULT_CASE
const objectOption = options[1] || {}
const caseChecker = casing.getChecker(caseType)
/** @type {RegExp[]} */
const ignores = (objectOption.ignores || []).map(toRegExp)
/**
* Check whether the given event name is valid.
* @param {string} name The name to check.
* @returns {boolean} `true` if the given event name is valid.
*/
function isValidEventName(name) {
return caseChecker(name) || name.startsWith('update:')
}
/**
* @param { NameWithLoc } nameWithLoc
*/
function verify(nameWithLoc) {
const name = nameWithLoc.name
if (isValidEventName(name) || ignores.some((re) => re.test(name))) {
return
}
context.report({
loc: nameWithLoc.loc,
messageId: 'unexpected',
data: {
name,
caseType
}
})
}
const programNode = context.getSourceCode().ast
const callVisitor = {
/**
* @param {CallExpression} node
* @param {VueObjectData} [info]
*/
CallExpression(node, info) {
const nameWithLoc = getNameParamNode(node)
if (!nameWithLoc) {
// cannot check
return
}
// verify setup context
const setupContext = setupContexts.get(info ? info.node : programNode)
if (setupContext) {
const { contextReferenceIds, emitReferenceIds } = setupContext
if (
node.callee.type === 'Identifier' &&
emitReferenceIds.has(node.callee)
) {
// verify setup(props,{emit}) {emit()}
verify(nameWithLoc)
} else {
const emit = getCalleeMemberNode(node)
if (
emit &&
emit.name === 'emit' &&
emit.member.object.type === 'Identifier' &&
contextReferenceIds.has(emit.member.object)
) {
// verify setup(props,context) {context.emit()}
verify(nameWithLoc)
}
}
}
}
}
return utils.defineTemplateBodyVisitor(
context,
{
CallExpression(node) {
const callee = node.callee
const nameWithLoc = getNameParamNode(node)
if (!nameWithLoc) {
// cannot check
return
}
if (callee.type === 'Identifier' && callee.name === '$emit') {
verify(nameWithLoc)
}
}
},
utils.compositingVisitors(
utils.defineScriptSetupVisitor(context, {
onDefineEmitsEnter(node) {
if (
!node.parent ||
node.parent.type !== 'VariableDeclarator' ||
node.parent.init !== node
) {
return
}
const emitParam = node.parent.id
if (emitParam.type !== 'Identifier') {
return
}
// const emit = defineEmits()
const variable = findVariable(
utils.getScope(context, emitParam),
emitParam
)
if (!variable) {
return
}
const emitReferenceIds = new Set()
for (const reference of variable.references) {
emitReferenceIds.add(reference.identifier)
}
setupContexts.set(programNode, {
contextReferenceIds: new Set(),
emitReferenceIds
})
},
...callVisitor
}),
utils.defineVueVisitor(context, {
onSetupFunctionEnter(node, { node: vueNode }) {
const contextParam = utils.skipDefaultParamValue(node.params[1])
if (!contextParam) {
// no arguments
return
}
if (
contextParam.type === 'RestElement' ||
contextParam.type === 'ArrayPattern'
) {
// cannot check
return
}
const contextReferenceIds = new Set()
const emitReferenceIds = new Set()
if (contextParam.type === 'ObjectPattern') {
const emitProperty = utils.findAssignmentProperty(
contextParam,
'emit'
)
if (!emitProperty || emitProperty.value.type !== 'Identifier') {
return
}
const emitParam = emitProperty.value
// `setup(props, {emit})`
const variable = findVariable(
utils.getScope(context, emitParam),
emitParam
)
if (!variable) {
return
}
for (const reference of variable.references) {
emitReferenceIds.add(reference.identifier)
}
} else {
// `setup(props, context)`
const variable = findVariable(
utils.getScope(context, contextParam),
contextParam
)
if (!variable) {
return
}
for (const reference of variable.references) {
contextReferenceIds.add(reference.identifier)
}
}
setupContexts.set(vueNode, {
contextReferenceIds,
emitReferenceIds
})
},
...callVisitor,
onVueObjectExit(node) {
setupContexts.delete(node)
}
}),
{
CallExpression(node) {
const nameLiteralNode = getNameParamNode(node)
if (!nameLiteralNode) {
// cannot check
return
}
const emit = getCalleeMemberNode(node)
// verify $emit
if (emit && emit.name === '$emit') {
// verify this.$emit()
verify(nameLiteralNode)
}
}
}
)
)
}
}

View File

@ -0,0 +1,106 @@
/**
* @author Amorites
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {import('@typescript-eslint/types').TSESTree.TypeNode} TypeNode
*
*/
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce declaration style of `defineEmits`',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/define-emits-declaration.html'
},
fixable: null,
schema: [
{
enum: ['type-based', 'type-literal', 'runtime']
}
],
messages: {
hasArg: 'Use type based declaration instead of runtime declaration.',
hasTypeArg: 'Use runtime declaration instead of type based declaration.',
hasTypeCallArg:
'Use new type literal declaration instead of the old call signature declaration.'
}
},
/** @param {RuleContext} context */
create(context) {
const scriptSetup = utils.getScriptSetupElement(context)
if (!scriptSetup || !utils.hasAttribute(scriptSetup, 'lang', 'ts')) {
return {}
}
const defineType = context.options[0] || 'type-based'
return utils.defineScriptSetupVisitor(context, {
onDefineEmitsEnter(node) {
switch (defineType) {
case 'type-based': {
if (node.arguments.length > 0) {
context.report({
node,
messageId: 'hasArg'
})
}
break
}
case 'type-literal': {
verifyTypeLiteral(node)
break
}
case 'runtime': {
const typeArguments =
'typeArguments' in node ? node.typeArguments : node.typeParameters
if (typeArguments && typeArguments.params.length > 0) {
context.report({
node,
messageId: 'hasTypeArg'
})
}
break
}
}
}
})
/** @param {CallExpression} node */
function verifyTypeLiteral(node) {
if (node.arguments.length > 0) {
context.report({
node,
messageId: 'hasArg'
})
return
}
const typeArguments = node.typeArguments || node.typeParameters
const param = /** @type {TypeNode|undefined} */ (typeArguments?.params[0])
if (!param) return
if (param.type === 'TSTypeLiteral') {
for (const memberNode of param.members) {
if (memberNode.type !== 'TSPropertySignature') {
context.report({
node: memberNode,
messageId: 'hasTypeCallArg'
})
}
}
} else if (param.type === 'TSFunctionType') {
context.report({
node: param,
messageId: 'hasTypeCallArg'
})
}
}
}
}

View File

@ -0,0 +1,371 @@
/**
* @author Eduard Deisling
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const MACROS_EMITS = 'defineEmits'
const MACROS_PROPS = 'defineProps'
const MACROS_OPTIONS = 'defineOptions'
const MACROS_SLOTS = 'defineSlots'
const MACROS_MODEL = 'defineModel'
const ORDER_SCHEMA = [
MACROS_EMITS,
MACROS_PROPS,
MACROS_OPTIONS,
MACROS_SLOTS,
MACROS_MODEL
]
const DEFAULT_ORDER = [MACROS_PROPS, MACROS_EMITS]
/**
* @param {VElement} scriptSetup
* @param {ASTNode} node
*/
function inScriptSetup(scriptSetup, node) {
return (
scriptSetup.range[0] <= node.range[0] &&
node.range[1] <= scriptSetup.range[1]
)
}
/**
* @param {ASTNode} node
*/
function isUseStrictStatement(node) {
return (
node.type === 'ExpressionStatement' &&
node.expression.type === 'Literal' &&
node.expression.value === 'use strict'
)
}
/**
* Get an index of the first statement after imports and interfaces in order
* to place defineEmits and defineProps before this statement
* @param {VElement} scriptSetup
* @param {Program} program
*/
function getTargetStatementPosition(scriptSetup, program) {
const skipStatements = new Set([
'ImportDeclaration',
'TSInterfaceDeclaration',
'TSTypeAliasDeclaration',
'DebuggerStatement',
'EmptyStatement',
'ExportNamedDeclaration'
])
for (const [index, item] of program.body.entries()) {
if (
inScriptSetup(scriptSetup, item) &&
!skipStatements.has(item.type) &&
!isUseStrictStatement(item)
) {
return index
}
}
return -1
}
/**
* We need to handle cases like "const props = defineProps(...)"
* Define macros must be used only on top, so we can look for "Program" type
* inside node.parent.type
* @param {CallExpression|ASTNode} node
* @return {ASTNode}
*/
function getDefineMacrosStatement(node) {
if (!node.parent) {
throw new Error('Node has no parent')
}
if (node.parent.type === 'Program') {
return node
}
return getDefineMacrosStatement(node.parent)
}
/** @param {RuleContext} context */
function create(context) {
const scriptSetup = utils.getScriptSetupElement(context)
if (!scriptSetup) {
return {}
}
const sourceCode = context.getSourceCode()
const options = context.options
/** @type {[string, string]} */
const order = (options[0] && options[0].order) || DEFAULT_ORDER
/** @type {boolean} */
const defineExposeLast = (options[0] && options[0].defineExposeLast) || false
/** @type {Map<string, ASTNode[]>} */
const macrosNodes = new Map()
/** @type {ASTNode} */
let defineExposeNode
return utils.compositingVisitors(
utils.defineScriptSetupVisitor(context, {
onDefinePropsExit(node) {
macrosNodes.set(MACROS_PROPS, [getDefineMacrosStatement(node)])
},
onDefineEmitsExit(node) {
macrosNodes.set(MACROS_EMITS, [getDefineMacrosStatement(node)])
},
onDefineOptionsExit(node) {
macrosNodes.set(MACROS_OPTIONS, [getDefineMacrosStatement(node)])
},
onDefineSlotsExit(node) {
macrosNodes.set(MACROS_SLOTS, [getDefineMacrosStatement(node)])
},
onDefineModelExit(node) {
const currentModelMacros = macrosNodes.get(MACROS_MODEL) ?? []
currentModelMacros.push(getDefineMacrosStatement(node))
macrosNodes.set(MACROS_MODEL, currentModelMacros)
},
onDefineExposeExit(node) {
defineExposeNode = getDefineMacrosStatement(node)
}
}),
{
'Program:exit'(program) {
/**
* @typedef {object} OrderedData
* @property {string} name
* @property {ASTNode} node
*/
const firstStatementIndex = getTargetStatementPosition(
scriptSetup,
program
)
const orderedList = order
.flatMap((name) => {
const nodes = macrosNodes.get(name) ?? []
return nodes.map((node) => ({ name, node }))
})
.filter(
/** @returns {data is OrderedData} */
(data) => utils.isDef(data.node)
)
// check last node
if (defineExposeLast) {
const lastNode = program.body[program.body.length - 1]
if (defineExposeNode && lastNode !== defineExposeNode) {
reportExposeNotOnBottom(defineExposeNode, lastNode)
}
}
for (const [index, should] of orderedList.entries()) {
const targetStatement = program.body[firstStatementIndex + index]
if (should.node !== targetStatement) {
let moveTargetNodes = orderedList
.slice(index)
.map(({ node }) => node)
const targetStatementIndex =
moveTargetNodes.indexOf(targetStatement)
if (targetStatementIndex >= 0) {
moveTargetNodes = moveTargetNodes.slice(0, targetStatementIndex)
}
reportNotOnTop(should.name, moveTargetNodes, targetStatement)
return
}
}
}
}
)
/**
* @param {string} macro
* @param {ASTNode[]} nodes
* @param {ASTNode} before
*/
function reportNotOnTop(macro, nodes, before) {
context.report({
node: nodes[0],
loc: nodes[0].loc,
messageId: 'macrosNotOnTop',
data: {
macro
},
*fix(fixer) {
for (const node of nodes) {
yield* moveNodeBefore(fixer, node, before)
}
}
})
}
/**
* @param {ASTNode} node
* @param {ASTNode} lastNode
*/
function reportExposeNotOnBottom(node, lastNode) {
context.report({
node,
loc: node.loc,
messageId: 'defineExposeNotTheLast',
suggest: [
{
messageId: 'putExposeAtTheLast',
fix(fixer) {
return moveNodeToLast(fixer, node, lastNode)
}
}
]
})
}
/**
* Move all lines of "node" with its comments to after the "target"
* @param {RuleFixer} fixer
* @param {ASTNode} node
* @param {ASTNode} target
*/
function moveNodeToLast(fixer, node, target) {
// get comments under tokens(if any)
const beforeNodeToken = sourceCode.getTokenBefore(node)
const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, {
includeComments: true
})
const nextNodeComment = sourceCode.getTokenAfter(node, {
includeComments: true
})
// remove position: node (and comments) to next node (and comments)
const cutStart = getLineStartIndex(nodeComment, beforeNodeToken)
const cutEnd = getLineStartIndex(nextNodeComment, node)
// insert text: comment + node
const textNode = sourceCode.getText(
node,
node.range[0] - beforeNodeToken.range[1]
)
return [
fixer.insertTextAfter(target, textNode),
fixer.removeRange([cutStart, cutEnd])
]
}
/**
* Move all lines of "node" with its comments to before the "target"
* @param {RuleFixer} fixer
* @param {ASTNode} node
* @param {ASTNode} target
*/
function moveNodeBefore(fixer, node, target) {
// get comments under tokens(if any)
const beforeNodeToken = sourceCode.getTokenBefore(node)
const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, {
includeComments: true
})
const nextNodeComment = sourceCode.getTokenAfter(node, {
includeComments: true
})
// get positions of what we need to remove
const cutStart = getLineStartIndex(nodeComment, beforeNodeToken)
const cutEnd = getLineStartIndex(nextNodeComment, node)
// get space before target
const beforeTargetToken = sourceCode.getTokenBefore(target)
const targetComment = sourceCode.getTokenAfter(beforeTargetToken, {
includeComments: true
})
// make insert text: comments + node + space before target
const textNode = sourceCode.getText(
node,
node.range[0] - nodeComment.range[0]
)
const insertText = getInsertText(textNode, target)
return [
fixer.insertTextBefore(targetComment, insertText),
fixer.removeRange([cutStart, cutEnd])
]
}
/**
* Get result text to insert
* @param {string} textNode
* @param {ASTNode} target
*/
function getInsertText(textNode, target) {
const afterTargetComment = sourceCode.getTokenAfter(target, {
includeComments: true
})
const afterText = sourceCode.text.slice(
target.range[1],
afterTargetComment.range[0]
)
// handle case when a();b() -> b()a();
const invalidResult = !textNode.endsWith(';') && !afterText.includes('\n')
return textNode + afterText + (invalidResult ? ';' : '')
}
/**
* Get position of the beginning of the token's line(or prevToken end if no line)
* @param {ASTNode|Token} token
* @param {ASTNode|Token} prevToken
*/
function getLineStartIndex(token, prevToken) {
// if we have next token on the same line - get index right before that token
if (token.loc.start.line === prevToken.loc.end.line) {
return prevToken.range[1]
}
return sourceCode.getIndexFromLoc({
line: token.loc.start.line,
column: 0
})
}
}
module.exports = {
meta: {
type: 'layout',
docs: {
description:
'enforce order of `defineEmits` and `defineProps` compiler macros',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/define-macros-order.html'
},
fixable: 'code',
hasSuggestions: true,
schema: [
{
type: 'object',
properties: {
order: {
type: 'array',
items: {
enum: ORDER_SCHEMA
},
uniqueItems: true,
additionalItems: false
},
defineExposeLast: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
macrosNotOnTop:
'{{macro}} should be the first statement in `<script setup>` (after any potential import statements or type definitions).',
defineExposeNotTheLast:
'`defineExpose` should be the last statement in `<script setup>`.',
putExposeAtTheLast:
'Put `defineExpose` as the last statement in `<script setup>`.'
}
},
create
}

View File

@ -0,0 +1,64 @@
/**
* @author Amorites
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce declaration style of `defineProps`',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/define-props-declaration.html'
},
fixable: null,
schema: [
{
enum: ['type-based', 'runtime']
}
],
messages: {
hasArg: 'Use type-based declaration instead of runtime declaration.',
hasTypeArg: 'Use runtime declaration instead of type-based declaration.'
}
},
/** @param {RuleContext} context */
create(context) {
const scriptSetup = utils.getScriptSetupElement(context)
if (!scriptSetup || !utils.hasAttribute(scriptSetup, 'lang', 'ts')) {
return {}
}
const defineType = context.options[0] || 'type-based'
return utils.defineScriptSetupVisitor(context, {
onDefinePropsEnter(node) {
switch (defineType) {
case 'type-based': {
if (node.arguments.length > 0) {
context.report({
node,
messageId: 'hasArg'
})
}
break
}
case 'runtime': {
const typeArguments =
'typeArguments' in node ? node.typeArguments : node.typeParameters
if (typeArguments && typeArguments.params.length > 0) {
context.report({
node,
messageId: 'hasTypeArg'
})
}
break
}
}
}
})
}
}

View File

@ -0,0 +1,9 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('dot-location')

View File

@ -0,0 +1,11 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('dot-notation', {
applyDocument: true
})

View File

@ -0,0 +1,154 @@
/**
* @author Mussin Benarbia
* See LICENSE file in root directory for full license.
*/
'use strict'
const { isVElement } = require('../utils')
/**
* check whether a tag has the `scoped` attribute
* @param {VElement} componentBlock
*/
function isScoped(componentBlock) {
return componentBlock.startTag.attributes.some(
(attribute) => !attribute.directive && attribute.key.name === 'scoped'
)
}
/**
* check whether a tag has the `module` attribute
* @param {VElement} componentBlock
*/
function isModule(componentBlock) {
return componentBlock.startTag.attributes.some(
(attribute) => !attribute.directive && attribute.key.name === 'module'
)
}
/**
* check if a tag doesn't have either the `scoped` nor `module` attribute
* @param {VElement} componentBlock
*/
function isPlain(componentBlock) {
return !isScoped(componentBlock) && !isModule(componentBlock)
}
/** @param {RuleContext} context */
function getUserDefinedAllowedAttrs(context) {
if (context.options[0] && context.options[0].allow) {
return context.options[0].allow
}
return []
}
const defaultAllowedAttrs = ['scoped']
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'enforce or forbid the use of the `scoped` and `module` attributes in SFC top level style tags',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/enforce-style-attribute.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
minItems: 1,
uniqueItems: true,
items: {
type: 'string',
enum: ['plain', 'scoped', 'module']
}
}
},
additionalProperties: false
}
],
messages: {
notAllowedScoped:
'The scoped attribute is not allowed. Allowed: {{ allowedAttrsString }}.',
notAllowedModule:
'The module attribute is not allowed. Allowed: {{ allowedAttrsString }}.',
notAllowedPlain:
'Plain <style> tags are not allowed. Allowed: {{ allowedAttrsString }}.'
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
if (!sourceCode.parserServices.getDocumentFragment) {
return {}
}
const documentFragment = sourceCode.parserServices.getDocumentFragment()
if (!documentFragment) {
return {}
}
const topLevelElements = documentFragment.children.filter(isVElement)
const topLevelStyleTags = topLevelElements.filter(
(element) => element.rawName === 'style'
)
if (topLevelStyleTags.length === 0) {
return {}
}
const userDefinedAllowedAttrs = getUserDefinedAllowedAttrs(context)
const allowedAttrs =
userDefinedAllowedAttrs.length > 0
? userDefinedAllowedAttrs
: defaultAllowedAttrs
const allowsPlain = allowedAttrs.includes('plain')
const allowsScoped = allowedAttrs.includes('scoped')
const allowsModule = allowedAttrs.includes('module')
const allowedAttrsString = [...allowedAttrs].sort().join(', ')
return {
Program() {
for (const styleTag of topLevelStyleTags) {
if (!allowsPlain && isPlain(styleTag)) {
context.report({
node: styleTag,
messageId: 'notAllowedPlain',
data: {
allowedAttrsString
}
})
return
}
if (!allowsScoped && isScoped(styleTag)) {
context.report({
node: styleTag,
messageId: 'notAllowedScoped',
data: {
allowedAttrsString
}
})
return
}
if (!allowsModule && isModule(styleTag)) {
context.report({
node: styleTag,
messageId: 'notAllowedModule',
data: {
allowedAttrsString
}
})
return
}
}
}
}
}
}

View File

@ -0,0 +1,11 @@
/**
* @author Toru Nagashima
*/
'use strict'
const { wrapCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapCoreRule('eqeqeq', {
applyDocument: true
})

View File

@ -0,0 +1,96 @@
/**
* @fileoverview Enforce the location of first attribute
* @author Yosuke Ota
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce the location of first attribute',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/first-attribute-linebreak.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
multiline: { enum: ['below', 'beside', 'ignore'] },
singleline: { enum: ['below', 'beside', 'ignore'] }
},
additionalProperties: false
}
],
messages: {
expected: 'Expected a linebreak before this attribute.',
unexpected: 'Expected no linebreak before this attribute.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @type {"below" | "beside" | "ignore"} */
const singleline =
(context.options[0] && context.options[0].singleline) || 'ignore'
/** @type {"below" | "beside" | "ignore"} */
const multiline =
(context.options[0] && context.options[0].multiline) || 'below'
const sourceCode = context.getSourceCode()
const template =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
/**
* Report attribute
* @param {VAttribute | VDirective} firstAttribute
* @param { "below" | "beside"} location
*/
function report(firstAttribute, location) {
context.report({
node: firstAttribute,
messageId: location === 'beside' ? 'unexpected' : 'expected',
fix(fixer) {
const prevToken = template.getTokenBefore(firstAttribute, {
includeComments: true
})
return fixer.replaceTextRange(
[prevToken.range[1], firstAttribute.range[0]],
location === 'beside' ? ' ' : '\n'
)
}
})
}
return utils.defineTemplateBodyVisitor(context, {
VStartTag(node) {
const firstAttribute = node.attributes[0]
if (!firstAttribute) return
const lastAttribute = node.attributes[node.attributes.length - 1]
const location =
firstAttribute.loc.start.line === lastAttribute.loc.end.line
? singleline
: multiline
if (location === 'ignore') {
return
}
if (location === 'beside') {
if (node.loc.start.line === firstAttribute.loc.start.line) {
return
}
} else {
if (node.loc.start.line < firstAttribute.loc.start.line) {
return
}
}
report(firstAttribute, location)
}
})
}
}

View File

@ -0,0 +1,19 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule(
{
core: 'func-call-spacing',
stylistic: 'function-call-spacing',
vue: 'func-call-spacing'
},
{
skipDynamicArguments: true,
applyDocument: true
}
)

View File

@ -0,0 +1,139 @@
/**
* @fileoverview Disallow usage of button without an explicit type attribute
* @author Jonathan Santerre <jonathan@santerre.dev>
*/
'use strict'
const utils = require('../utils')
/**
*
* @param {string} type
* @returns {type is 'button' | 'submit' | 'reset'}
*/
function isButtonType(type) {
return type === 'button' || type === 'submit' || type === 'reset'
}
const optionDefaults = {
button: true,
submit: true,
reset: true
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'disallow usage of button without an explicit type attribute',
categories: null,
url: 'https://eslint.vuejs.org/rules/html-button-has-type.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
button: {
default: optionDefaults.button,
type: 'boolean'
},
submit: {
default: optionDefaults.submit,
type: 'boolean'
},
reset: {
default: optionDefaults.reset,
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
missingTypeAttribute: 'Missing an explicit type attribute for button.',
invalidTypeAttribute:
'{{value}} is an invalid value for button type attribute.',
forbiddenTypeAttribute:
'{{value}} is a forbidden value for button type attribute.',
emptyTypeAttribute: 'A value must be set for button type attribute.'
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
/**
* @typedef {object} Configuration
* @property {boolean} button
* @property {boolean} submit
* @property {boolean} reset
*/
/** @type {Configuration} */
const configuration = Object.assign({}, optionDefaults, context.options[0])
/**
* @param {ASTNode} node
* @param {string} messageId
* @param {any} [data]
*/
function report(node, messageId, data) {
context.report({
node,
messageId,
data
})
}
/**
* @param {VAttribute} attribute
*/
function validateAttribute(attribute) {
const value = attribute.value
if (!value || !value.value) {
report(value || attribute, 'emptyTypeAttribute')
return
}
const strValue = value.value
if (!isButtonType(strValue)) {
report(value, 'invalidTypeAttribute', { value: strValue })
} else if (!configuration[strValue]) {
report(value, 'forbiddenTypeAttribute', { value: strValue })
}
}
/**
* @param {VDirective} directive
*/
function validateDirective(directive) {
const value = directive.value
if (!value || !value.expression) {
report(value || directive, 'emptyTypeAttribute')
}
}
return utils.defineTemplateBodyVisitor(context, {
/**
* @param {VElement} node
*/
"VElement[rawName='button']"(node) {
const typeAttr = utils.getAttribute(node, 'type')
if (typeAttr) {
validateAttribute(typeAttr)
return
}
const typeDir = utils.getDirective(node, 'bind', 'type')
if (typeDir) {
validateDirective(typeDir)
return
}
report(node.startTag, 'missingTypeAttribute')
}
})
}
}

View File

@ -0,0 +1,160 @@
/**
* @author Toru Nagashima
* @copyright 2016 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @param {number} lineBreaks
*/
function getPhrase(lineBreaks) {
switch (lineBreaks) {
case 0: {
return 'no line breaks'
}
case 1: {
return '1 line break'
}
default: {
return `${lineBreaks} line breaks`
}
}
}
/**
* @typedef LineBreakBehavior
* @type {('always'|'never')}
*/
/**
* @typedef LineType
* @type {('singleline'|'multiline')}
*/
/**
* @typedef RuleOptions
* @type {object}
* @property {LineBreakBehavior} singleline - The behavior for single line tags.
* @property {LineBreakBehavior} multiline - The behavior for multiline tags.
* @property {object} selfClosingTag
* @property {LineBreakBehavior} selfClosingTag.singleline - The behavior for single line self closing tags.
* @property {LineBreakBehavior} selfClosingTag.multiline - The behavior for multiline self closing tags.
*/
/**
* @param {VStartTag | VEndTag} node - The node representing a start or end tag.
* @param {RuleOptions} options - The options for line breaks.
* @param {LineType} type - The type of line break.
* @returns {number} - The expected line breaks.
*/
function getExpectedLineBreaks(node, options, type) {
const isSelfClosingTag = node.type === 'VStartTag' && node.selfClosing
if (
isSelfClosingTag &&
options.selfClosingTag &&
options.selfClosingTag[type]
) {
return options.selfClosingTag[type] === 'always' ? 1 : 0
}
return options[type] === 'always' ? 1 : 0
}
module.exports = {
meta: {
type: 'layout',
docs: {
description:
"require or disallow a line break before tag's closing brackets",
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/html-closing-bracket-newline.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
singleline: { enum: ['always', 'never'] },
multiline: { enum: ['always', 'never'] },
selfClosingTag: {
type: 'object',
properties: {
singleline: { enum: ['always', 'never'] },
multiline: { enum: ['always', 'never'] }
},
additionalProperties: false,
minProperties: 1
}
},
additionalProperties: false
}
],
messages: {
expectedBeforeClosingBracket:
'Expected {{expected}} before closing bracket, but {{actual}} found.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = Object.assign(
{},
{
singleline: 'never',
multiline: 'always'
},
context.options[0] || {}
)
const sourceCode = context.getSourceCode()
const template =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
return utils.defineDocumentVisitor(context, {
/** @param {VStartTag | VEndTag} node */
'VStartTag, VEndTag'(node) {
const closingBracketToken = template.getLastToken(node)
if (
closingBracketToken.type !== 'HTMLSelfClosingTagClose' &&
closingBracketToken.type !== 'HTMLTagClose'
) {
return
}
const prevToken = template.getTokenBefore(closingBracketToken)
const type =
node.loc.start.line === prevToken.loc.end.line
? 'singleline'
: 'multiline'
const expectedLineBreaks = getExpectedLineBreaks(node, options, type)
const actualLineBreaks =
closingBracketToken.loc.start.line - prevToken.loc.end.line
if (actualLineBreaks !== expectedLineBreaks) {
context.report({
node,
loc: {
start: prevToken.loc.end,
end: closingBracketToken.loc.start
},
messageId: 'expectedBeforeClosingBracket',
data: {
expected: getPhrase(expectedLineBreaks),
actual: getPhrase(actualLineBreaks)
},
fix(fixer) {
/** @type {Range} */
const range = [prevToken.range[1], closingBracketToken.range[0]]
const text = '\n'.repeat(expectedLineBreaks)
return fixer.replaceTextRange(range, text)
}
})
}
}
})
}
}

View File

@ -0,0 +1,129 @@
/**
* @author Toru Nagashima <https://github.com/mysticatea>
*/
'use strict'
const utils = require('../utils')
/**
* @typedef { {startTag?:"always"|"never",endTag?:"always"|"never",selfClosingTag?:"always"|"never"} } Options
*/
/**
* Normalize options.
* @param {Options} options The options user configured.
* @param {ParserServices.TokenStore} tokens The token store of template body.
* @returns {Options & { detectType: (node: VStartTag | VEndTag) => 'never' | 'always' | null }} The normalized options.
*/
function parseOptions(options, tokens) {
const opts = Object.assign(
{
startTag: 'never',
endTag: 'never',
selfClosingTag: 'always'
},
options
)
return Object.assign(opts, {
/**
* @param {VStartTag | VEndTag} node
* @returns {'never' | 'always' | null}
*/
detectType(node) {
const openType = tokens.getFirstToken(node).type
const closeType = tokens.getLastToken(node).type
if (openType === 'HTMLEndTagOpen' && closeType === 'HTMLTagClose') {
return opts.endTag
}
if (openType === 'HTMLTagOpen' && closeType === 'HTMLTagClose') {
return opts.startTag
}
if (
openType === 'HTMLTagOpen' &&
closeType === 'HTMLSelfClosingTagClose'
) {
return opts.selfClosingTag
}
return null
}
})
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: "require or disallow a space before tag's closing brackets",
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/html-closing-bracket-spacing.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
startTag: { enum: ['always', 'never'] },
endTag: { enum: ['always', 'never'] },
selfClosingTag: { enum: ['always', 'never'] }
},
additionalProperties: false
}
],
messages: {
missing: "Expected a space before '{{bracket}}', but not found.",
unexpected: "Expected no space before '{{bracket}}', but found."
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const tokens =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
const options = parseOptions(context.options[0], tokens)
return utils.defineDocumentVisitor(context, {
/** @param {VStartTag | VEndTag} node */
'VStartTag, VEndTag'(node) {
const type = options.detectType(node)
const lastToken = tokens.getLastToken(node)
const prevToken = tokens.getLastToken(node, 1)
// Skip if EOF exists in the tag or linebreak exists before `>`.
if (
type == null ||
prevToken == null ||
prevToken.loc.end.line !== lastToken.loc.start.line
) {
return
}
// Check and report.
const hasSpace = prevToken.range[1] !== lastToken.range[0]
if (type === 'always' && !hasSpace) {
context.report({
node,
loc: lastToken.loc,
messageId: 'missing',
data: { bracket: sourceCode.getText(lastToken) },
fix: (fixer) => fixer.insertTextBefore(lastToken, ' ')
})
} else if (type === 'never' && hasSpace) {
context.report({
node,
loc: {
start: prevToken.loc.end,
end: lastToken.loc.end
},
messageId: 'unexpected',
data: { bracket: sourceCode.getText(lastToken) },
fix: (fixer) =>
fixer.removeRange([prevToken.range[1], lastToken.range[0]])
})
}
}
})
}
}

View File

@ -0,0 +1,203 @@
/**
* @author Yosuke ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const htmlComments = require('../utils/html-comments')
/**
* @typedef { import('../utils/html-comments').ParsedHTMLComment } ParsedHTMLComment
*/
/**
* @param {any} param
*/
function parseOption(param) {
if (param && typeof param === 'string') {
return {
singleline: param,
multiline: param
}
}
return Object.assign(
{
singleline: 'never',
multiline: 'always'
},
param
)
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce unified line brake in HTML comments',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/html-comment-content-newline.html'
},
fixable: 'whitespace',
schema: [
{
anyOf: [
{
enum: ['always', 'never']
},
{
type: 'object',
properties: {
singleline: { enum: ['always', 'never', 'ignore'] },
multiline: { enum: ['always', 'never', 'ignore'] }
},
additionalProperties: false
}
]
},
{
type: 'object',
properties: {
exceptions: {
type: 'array',
items: {
type: 'string'
}
}
},
additionalProperties: false
}
],
messages: {
expectedAfterHTMLCommentOpen: "Expected line break after '<!--'.",
expectedBeforeHTMLCommentOpen: "Expected line break before '-->'.",
expectedAfterExceptionBlock: 'Expected line break after exception block.',
expectedBeforeExceptionBlock:
'Expected line break before exception block.',
unexpectedAfterHTMLCommentOpen: "Unexpected line breaks after '<!--'.",
unexpectedBeforeHTMLCommentOpen: "Unexpected line breaks before '-->'."
}
},
/** @param {RuleContext} context */
create(context) {
const option = parseOption(context.options[0])
return htmlComments.defineVisitor(
context,
context.options[1],
(comment) => {
const { value, openDecoration, closeDecoration } = comment
if (!value) {
return
}
const startLine = openDecoration
? openDecoration.loc.end.line
: value.loc.start.line
const endLine = closeDecoration
? closeDecoration.loc.start.line
: value.loc.end.line
const newlineType =
startLine === endLine ? option.singleline : option.multiline
if (newlineType === 'ignore') {
return
}
checkCommentOpen(comment, newlineType !== 'never')
checkCommentClose(comment, newlineType !== 'never')
}
)
/**
* Reports the newline before the contents of a given comment if it's invalid.
* @param {ParsedHTMLComment} comment - comment data.
* @param {boolean} requireNewline - `true` if line breaks are required.
* @returns {void}
*/
function checkCommentOpen(comment, requireNewline) {
const { value, openDecoration, open } = comment
if (!value) {
return
}
const beforeToken = openDecoration || open
if (requireNewline) {
if (beforeToken.loc.end.line < value.loc.start.line) {
// Is valid
return
}
context.report({
loc: {
start: beforeToken.loc.end,
end: value.loc.start
},
messageId: openDecoration
? 'expectedAfterExceptionBlock'
: 'expectedAfterHTMLCommentOpen',
fix: openDecoration
? undefined
: (fixer) => fixer.insertTextAfter(beforeToken, '\n')
})
} else {
if (beforeToken.loc.end.line === value.loc.start.line) {
// Is valid
return
}
context.report({
loc: {
start: beforeToken.loc.end,
end: value.loc.start
},
messageId: 'unexpectedAfterHTMLCommentOpen',
fix: (fixer) =>
fixer.replaceTextRange([beforeToken.range[1], value.range[0]], ' ')
})
}
}
/**
* Reports the space after the contents of a given comment if it's invalid.
* @param {ParsedHTMLComment} comment - comment data.
* @param {boolean} requireNewline - `true` if line breaks are required.
* @returns {void}
*/
function checkCommentClose(comment, requireNewline) {
const { value, closeDecoration, close } = comment
if (!value) {
return
}
const afterToken = closeDecoration || close
if (requireNewline) {
if (value.loc.end.line < afterToken.loc.start.line) {
// Is valid
return
}
context.report({
loc: {
start: value.loc.end,
end: afterToken.loc.start
},
messageId: closeDecoration
? 'expectedBeforeExceptionBlock'
: 'expectedBeforeHTMLCommentOpen',
fix: closeDecoration
? undefined
: (fixer) => fixer.insertTextBefore(afterToken, '\n')
})
} else {
if (value.loc.end.line === afterToken.loc.start.line) {
// Is valid
return
}
context.report({
loc: {
start: value.loc.end,
end: afterToken.loc.start
},
messageId: 'unexpectedBeforeHTMLCommentOpen',
fix: (fixer) =>
fixer.replaceTextRange([value.range[1], afterToken.range[0]], ' ')
})
}
}
}
}

View File

@ -0,0 +1,171 @@
/**
* @author Yosuke ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const htmlComments = require('../utils/html-comments')
/**
* @typedef { import('../utils/html-comments').ParsedHTMLComment } ParsedHTMLComment
*/
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce unified spacing in HTML comments',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/html-comment-content-spacing.html'
},
fixable: 'whitespace',
schema: [
{
enum: ['always', 'never']
},
{
type: 'object',
properties: {
exceptions: {
type: 'array',
items: {
type: 'string'
}
}
},
additionalProperties: false
}
],
messages: {
expectedAfterHTMLCommentOpen: "Expected space after '<!--'.",
expectedBeforeHTMLCommentOpen: "Expected space before '-->'.",
expectedAfterExceptionBlock: 'Expected space after exception block.',
expectedBeforeExceptionBlock: 'Expected space before exception block.',
unexpectedAfterHTMLCommentOpen: "Unexpected space after '<!--'.",
unexpectedBeforeHTMLCommentOpen: "Unexpected space before '-->'."
}
},
/** @param {RuleContext} context */
create(context) {
// Unless the first option is never, require a space
const requireSpace = context.options[0] !== 'never'
return htmlComments.defineVisitor(
context,
context.options[1],
(comment) => {
checkCommentOpen(comment)
checkCommentClose(comment)
},
{ includeDirectives: true }
)
/**
* Reports the space before the contents of a given comment if it's invalid.
* @param {ParsedHTMLComment} comment - comment data.
* @returns {void}
*/
function checkCommentOpen(comment) {
const { value, openDecoration, open } = comment
if (!value) {
return
}
const beforeToken = openDecoration || open
if (beforeToken.loc.end.line !== value.loc.start.line) {
// Ignore newline
return
}
if (requireSpace) {
if (beforeToken.range[1] < value.range[0]) {
// Is valid
return
}
context.report({
loc: {
start: beforeToken.loc.end,
end: value.loc.start
},
messageId: openDecoration
? 'expectedAfterExceptionBlock'
: 'expectedAfterHTMLCommentOpen',
fix: openDecoration
? undefined
: (fixer) => fixer.insertTextAfter(beforeToken, ' ')
})
} else {
if (openDecoration) {
// Ignore expection block
return
}
if (beforeToken.range[1] === value.range[0]) {
// Is valid
return
}
context.report({
loc: {
start: beforeToken.loc.end,
end: value.loc.start
},
messageId: 'unexpectedAfterHTMLCommentOpen',
fix: (fixer) =>
fixer.removeRange([beforeToken.range[1], value.range[0]])
})
}
}
/**
* Reports the space after the contents of a given comment if it's invalid.
* @param {ParsedHTMLComment} comment - comment data.
* @returns {void}
*/
function checkCommentClose(comment) {
const { value, closeDecoration, close } = comment
if (!value) {
return
}
const afterToken = closeDecoration || close
if (value.loc.end.line !== afterToken.loc.start.line) {
// Ignore newline
return
}
if (requireSpace) {
if (value.range[1] < afterToken.range[0]) {
// Is valid
return
}
context.report({
loc: {
start: value.loc.end,
end: afterToken.loc.start
},
messageId: closeDecoration
? 'expectedBeforeExceptionBlock'
: 'expectedBeforeHTMLCommentOpen',
fix: closeDecoration
? undefined
: (fixer) => fixer.insertTextBefore(afterToken, ' ')
})
} else {
if (closeDecoration) {
// Ignore expection block
return
}
if (value.range[1] === afterToken.range[0]) {
// Is valid
return
}
context.report({
loc: {
start: value.loc.end,
end: afterToken.loc.start
},
messageId: 'unexpectedBeforeHTMLCommentOpen',
fix: (fixer) =>
fixer.removeRange([value.range[1], afterToken.range[0]])
})
}
}
}
}

View File

@ -0,0 +1,244 @@
/**
* @author Yosuke ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const htmlComments = require('../utils/html-comments')
/**
* Normalize options.
* @param {number|"tab"|undefined} type The type of indentation.
* @returns { { indentChar: string, indentSize: number, indentText: string } } Normalized options.
*/
function parseOptions(type) {
const ret = {
indentChar: ' ',
indentSize: 2,
indentText: ''
}
if (Number.isSafeInteger(type)) {
ret.indentSize = Number(type)
} else if (type === 'tab') {
ret.indentChar = '\t'
ret.indentSize = 1
}
ret.indentText = ret.indentChar.repeat(ret.indentSize)
return ret
}
/**
* @param {string} s
* @param {string} [unitChar]
*/
function toDisplay(s, unitChar) {
if (s.length === 0 && unitChar) {
return `0 ${toUnit(unitChar)}s`
}
const char = s[0]
if ((char === ' ' || char === '\t') && [...s].every((c) => c === char)) {
return `${s.length} ${toUnit(char)}${s.length === 1 ? '' : 's'}`
}
return JSON.stringify(s)
}
/** @param {string} char */
function toUnit(char) {
if (char === '\t') {
return 'tab'
}
if (char === ' ') {
return 'space'
}
return JSON.stringify(char)
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce consistent indentation in HTML comments',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/html-comment-indent.html'
},
fixable: 'whitespace',
schema: [
{
anyOf: [{ type: 'integer', minimum: 0 }, { enum: ['tab'] }]
}
],
messages: {
unexpectedBaseIndentation:
'Expected base point indentation of {{expected}}, but found {{actual}}.',
missingBaseIndentation:
'Expected base point indentation of {{expected}}, but not found.',
unexpectedIndentationCharacter:
'Expected {{expected}} character, but found {{actual}} character.',
unexpectedIndentation:
'Expected indentation of {{expected}} but found {{actual}}.',
unexpectedRelativeIndentation:
'Expected relative indentation of {{expected}} but found {{actual}}.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = parseOptions(context.options[0])
const sourceCode = context.getSourceCode()
return htmlComments.defineVisitor(
context,
null,
(comment) => {
const baseIndentText = getLineIndentText(comment.open.loc.start.line)
let endLine
if (comment.value) {
const startLine = comment.value.loc.start.line
endLine = comment.value.loc.end.line
const checkStartLine =
comment.open.loc.end.line === startLine ? startLine + 1 : startLine
for (let line = checkStartLine; line <= endLine; line++) {
validateIndentForLine(line, baseIndentText, 1)
}
} else {
endLine = comment.open.loc.end.line
}
if (endLine < comment.close.loc.start.line) {
// `-->`
validateIndentForLine(comment.close.loc.start.line, baseIndentText, 0)
}
},
{ includeDirectives: true }
)
/**
* Checks whether the given line is a blank line.
* @param {number} line The number of line. Begins with 1.
* @returns {boolean} `true` if the given line is a blank line
*/
function isEmptyLine(line) {
const lineText = sourceCode.getLines()[line - 1]
return !lineText.trim()
}
/**
* Get the actual indentation of the given line.
* @param {number} line The number of line. Begins with 1.
* @returns {string} The actual indentation text
*/
function getLineIndentText(line) {
const lineText = sourceCode.getLines()[line - 1]
const charIndex = lineText.search(/\S/)
// already checked
// if (charIndex < 0) {
// return lineText
// }
return lineText.slice(0, charIndex)
}
/**
* Define the function which fixes the problem.
* @param {number} line The number of line.
* @param {string} actualIndentText The actual indentation text.
* @param {string} expectedIndentText The expected indentation text.
* @returns { (fixer: RuleFixer) => Fix } The defined function.
*/
function defineFix(line, actualIndentText, expectedIndentText) {
return (fixer) => {
const start = sourceCode.getIndexFromLoc({
line,
column: 0
})
return fixer.replaceTextRange(
[start, start + actualIndentText.length],
expectedIndentText
)
}
}
/**
* Validate the indentation of a line.
* @param {number} line The number of line. Begins with 1.
* @param {string} baseIndentText The expected base indentation text.
* @param {number} offset The number of the indentation offset.
*/
function validateIndentForLine(line, baseIndentText, offset) {
if (isEmptyLine(line)) {
return
}
const actualIndentText = getLineIndentText(line)
const expectedOffsetIndentText = options.indentText.repeat(offset)
const expectedIndentText = baseIndentText + expectedOffsetIndentText
// validate base indent
if (
baseIndentText &&
(actualIndentText.length < baseIndentText.length ||
!actualIndentText.startsWith(baseIndentText))
) {
context.report({
loc: {
start: { line, column: 0 },
end: { line, column: actualIndentText.length }
},
messageId: actualIndentText
? 'unexpectedBaseIndentation'
: 'missingBaseIndentation',
data: {
expected: toDisplay(baseIndentText),
actual: toDisplay(actualIndentText.slice(0, baseIndentText.length))
},
fix: defineFix(line, actualIndentText, expectedIndentText)
})
return
}
const actualOffsetIndentText = actualIndentText.slice(
baseIndentText.length
)
// validate indent charctor
for (const [i, char] of [...actualOffsetIndentText].entries()) {
if (char !== options.indentChar) {
context.report({
loc: {
start: { line, column: baseIndentText.length + i },
end: { line, column: baseIndentText.length + i + 1 }
},
messageId: 'unexpectedIndentationCharacter',
data: {
expected: toUnit(options.indentChar),
actual: toUnit(char)
},
fix: defineFix(line, actualIndentText, expectedIndentText)
})
return
}
}
// validate indent length
if (actualOffsetIndentText.length !== expectedOffsetIndentText.length) {
context.report({
loc: {
start: { line, column: baseIndentText.length },
end: { line, column: actualIndentText.length }
},
messageId: baseIndentText
? 'unexpectedRelativeIndentation'
: 'unexpectedIndentation',
data: {
expected: toDisplay(expectedOffsetIndentText, options.indentChar),
actual: toDisplay(actualOffsetIndentText, options.indentChar)
},
fix: defineFix(line, actualIndentText, expectedIndentText)
})
}
}
}
}

View File

@ -0,0 +1,59 @@
/**
* @author Toru Nagashima
* @copyright 2017 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce end tag style',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/html-end-tags.html'
},
fixable: 'code',
schema: [],
messages: {
missingEndTag: "'<{{name}}>' should have end tag."
}
},
/** @param {RuleContext} context */
create(context) {
let hasInvalidEOF = false
return utils.defineTemplateBodyVisitor(
context,
{
VElement(node) {
if (hasInvalidEOF) {
return
}
const name = node.name
const isVoid = utils.isHtmlVoidElementName(name)
const isSelfClosing = node.startTag.selfClosing
const hasEndTag = node.endTag != null
if (!isVoid && !hasEndTag && !isSelfClosing) {
context.report({
node: node.startTag,
loc: node.startTag.loc,
messageId: 'missingEndTag',
data: { name },
fix: (fixer) => fixer.insertTextAfter(node, `</${name}>`)
})
}
}
},
{
Program(node) {
hasInvalidEOF = utils.hasInvalidEOF(node)
}
}
)
}
}

View File

@ -0,0 +1,76 @@
/**
* @author Toru Nagashima
* @copyright 2016 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const indentCommon = require('../utils/indent-common')
const utils = require('../utils')
module.exports = {
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const tokenStore =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
const visitor = indentCommon.defineVisitor(context, tokenStore, {
baseIndent: 1
})
return utils.defineTemplateBodyVisitor(context, visitor)
},
// eslint-disable-next-line eslint-plugin/prefer-message-ids
meta: {
type: 'layout',
docs: {
description: 'enforce consistent indentation in `<template>`',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/html-indent.html'
},
// eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized
fixable: 'whitespace',
schema: [
{
anyOf: [{ type: 'integer', minimum: 1 }, { enum: ['tab'] }]
},
{
type: 'object',
properties: {
attribute: { type: 'integer', minimum: 0 },
baseIndent: { type: 'integer', minimum: 0 },
closeBracket: {
anyOf: [
{ type: 'integer', minimum: 0 },
{
type: 'object',
properties: {
startTag: { type: 'integer', minimum: 0 },
endTag: { type: 'integer', minimum: 0 },
selfClosingTag: { type: 'integer', minimum: 0 }
},
additionalProperties: false
}
]
},
switchCase: { type: 'integer', minimum: 0 },
alignAttributesVertically: { type: 'boolean' },
ignores: {
type: 'array',
items: {
allOf: [
{ type: 'string' },
{ not: { type: 'string', pattern: ':exit$' } },
{ not: { type: 'string', pattern: '^\\s*$' } }
]
},
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
]
}
}

View File

@ -0,0 +1,107 @@
/**
* @author Toru Nagashima
* @copyright 2017 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce quotes style of HTML attributes',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/html-quotes.html'
},
fixable: 'code',
schema: [
{ enum: ['double', 'single'] },
{
type: 'object',
properties: {
avoidEscape: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
expected: 'Expected to be enclosed by {{kind}}.'
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const double = context.options[0] !== 'single'
const avoidEscape =
context.options[1] && context.options[1].avoidEscape === true
const quoteChar = double ? '"' : "'"
const quoteName = double ? 'double quotes' : 'single quotes'
/** @type {boolean} */
let hasInvalidEOF
return utils.defineTemplateBodyVisitor(
context,
{
'VAttribute[value!=null]'(node) {
if (hasInvalidEOF) {
return
}
if (utils.isVBindSameNameShorthand(node)) {
// v-bind same-name shorthand (Vue 3.4+)
return
}
const text = sourceCode.getText(node.value)
const firstChar = text[0]
if (firstChar !== quoteChar) {
const quoted = firstChar === "'" || firstChar === '"'
if (avoidEscape && quoted) {
const contentText = text.slice(1, -1)
if (contentText.includes(quoteChar)) {
return
}
}
context.report({
node: node.value,
loc: node.value.loc,
messageId: 'expected',
data: { kind: quoteName },
fix(fixer) {
const contentText = quoted ? text.slice(1, -1) : text
let fixToDouble = double
if (avoidEscape && !quoted && contentText.includes(quoteChar)) {
fixToDouble = double
? contentText.includes("'")
: !contentText.includes('"')
}
const quotePattern = fixToDouble ? /"/g : /'/g
const quoteEscaped = fixToDouble ? '&quot;' : '&apos;'
const fixQuoteChar = fixToDouble ? '"' : "'"
const replacement =
fixQuoteChar +
contentText.replace(quotePattern, quoteEscaped) +
fixQuoteChar
return fixer.replaceText(node.value, replacement)
}
})
}
}
},
{
Program(node) {
hasInvalidEOF = utils.hasInvalidEOF(node)
}
}
)
}
}

View File

@ -0,0 +1,217 @@
/**
* @author Toru Nagashima
* @copyright 2016 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* These strings wil be displayed in error messages.
*/
const ELEMENT_TYPE_MESSAGES = Object.freeze({
NORMAL: 'HTML elements',
VOID: 'HTML void elements',
COMPONENT: 'Vue.js custom components',
SVG: 'SVG elements',
MATH: 'MathML elements',
UNKNOWN: 'unknown elements'
})
/**
* @typedef {object} Options
* @property {'always' | 'never'} NORMAL
* @property {'always' | 'never'} VOID
* @property {'always' | 'never'} COMPONENT
* @property {'always' | 'never'} SVG
* @property {'always' | 'never'} MATH
* @property {null} UNKNOWN
*/
/**
* Normalize the given options.
* @param {any} options The raw options object.
* @returns {Options} Normalized options.
*/
function parseOptions(options) {
return {
NORMAL: (options && options.html && options.html.normal) || 'always',
VOID: (options && options.html && options.html.void) || 'never',
COMPONENT: (options && options.html && options.html.component) || 'always',
SVG: (options && options.svg) || 'always',
MATH: (options && options.math) || 'always',
UNKNOWN: null
}
}
/**
* Get the elementType of the given element.
* @param {VElement} node The element node to get.
* @returns {keyof Options} The elementType of the element.
*/
function getElementType(node) {
if (utils.isCustomComponent(node)) {
return 'COMPONENT'
}
if (utils.isHtmlElementNode(node)) {
if (utils.isHtmlVoidElementName(node.name)) {
return 'VOID'
}
return 'NORMAL'
}
if (utils.isSvgElementNode(node)) {
return 'SVG'
}
if (utils.isMathMLElementNode(node)) {
return 'MATH'
}
return 'UNKNOWN'
}
/**
* Check whether the given element is empty or not.
* This ignores whitespaces, doesn't ignore comments.
* @param {VElement} node The element node to check.
* @param {SourceCode} sourceCode The source code object of the current context.
* @returns {boolean} `true` if the element is empty.
*/
function isEmpty(node, sourceCode) {
const start = node.startTag.range[1]
const end = node.endTag == null ? node.range[1] : node.endTag.range[0]
return sourceCode.text.slice(start, end).trim() === ''
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce self-closing style',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/html-self-closing.html'
},
fixable: 'code',
schema: {
definitions: {
optionValue: {
enum: ['always', 'never', 'any']
}
},
type: 'array',
items: [
{
type: 'object',
properties: {
html: {
type: 'object',
properties: {
normal: { $ref: '#/definitions/optionValue' },
void: { $ref: '#/definitions/optionValue' },
component: { $ref: '#/definitions/optionValue' }
},
additionalProperties: false
},
svg: { $ref: '#/definitions/optionValue' },
math: { $ref: '#/definitions/optionValue' }
},
additionalProperties: false
}
],
maxItems: 1
},
messages: {
requireSelfClosing:
'Require self-closing on {{elementType}} (<{{name}}>).',
disallowSelfClosing:
'Disallow self-closing on {{elementType}} (<{{name}}/>).'
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const options = parseOptions(context.options[0])
let hasInvalidEOF = false
return utils.defineTemplateBodyVisitor(
context,
{
VElement(node) {
if (hasInvalidEOF || node.parent.type === 'VDocumentFragment') {
return
}
const elementType = getElementType(node)
const mode = options[elementType]
if (
mode === 'always' &&
!node.startTag.selfClosing &&
isEmpty(node, sourceCode)
) {
context.report({
node,
loc: node.loc,
messageId: 'requireSelfClosing',
data: {
elementType: ELEMENT_TYPE_MESSAGES[elementType],
name: node.rawName
},
fix(fixer) {
const tokens =
sourceCode.parserServices.getTemplateBodyTokenStore()
const close = tokens.getLastToken(node.startTag)
if (close.type !== 'HTMLTagClose') {
return null
}
return fixer.replaceTextRange(
[close.range[0], node.range[1]],
'/>'
)
}
})
}
if (mode === 'never' && node.startTag.selfClosing) {
context.report({
node,
loc: node.loc,
messageId: 'disallowSelfClosing',
data: {
elementType: ELEMENT_TYPE_MESSAGES[elementType],
name: node.rawName
},
fix(fixer) {
const tokens =
sourceCode.parserServices.getTemplateBodyTokenStore()
const close = tokens.getLastToken(node.startTag)
if (close.type !== 'HTMLSelfClosingTagClose') {
return null
}
if (elementType === 'VOID') {
return fixer.replaceText(close, '>')
}
// If only `close` is targeted for replacement, it conflicts with `component-name-in-template-casing`,
// so replace the entire element.
// return fixer.replaceText(close, `></${node.rawName}>`)
const elementPart = sourceCode.text.slice(
node.range[0],
close.range[0]
)
return fixer.replaceText(
node,
`${elementPart}></${node.rawName}>`
)
}
})
}
}
},
{
Program(node) {
hasInvalidEOF = utils.hasInvalidEOF(node)
}
}
)
}
}

View File

@ -0,0 +1,72 @@
// the following rule is based on yannickcr/eslint-plugin-react
/**
The MIT License (MIT)
Copyright (c) 2014 Yannick Croissant
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/**
* @fileoverview Prevent variables used in JSX to be marked as unused
* @author Yannick Croissant
*/
'use strict'
const utils = require('../utils')
module.exports = {
// eslint-disable-next-line eslint-plugin/prefer-message-ids
meta: {
type: 'problem',
docs: {
description: 'prevent variables used in JSX to be marked as unused', // eslint-disable-line eslint-plugin/require-meta-docs-description
categories: ['base'],
url: 'https://eslint.vuejs.org/rules/jsx-uses-vars.html'
},
schema: []
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
return {
JSXOpeningElement(node) {
let name
if (node.name.type === 'JSXIdentifier') {
// <Foo>
name = node.name.name
} else if (node.name.type === 'JSXMemberExpression') {
// <Foo...Bar>
let parent = node.name.object
while (parent.type === 'JSXMemberExpression') {
parent = parent.object
}
name = parent.name
} else {
return
}
utils.markVariableAsUsed(context, name, node)
}
}
}
}

View File

@ -0,0 +1,11 @@
/**
* @author Toru Nagashima
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('key-spacing', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,11 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('keyword-spacing', {
skipDynamicArguments: true
})

View File

@ -0,0 +1,164 @@
/**
* @fileoverview Require component name property to match its file name
* @author Rodrigo Pedra Brum <rodrigo.pedra@gmail.com>
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
const path = require('path')
/**
* @param {Expression | SpreadElement} node
* @returns {node is (Literal | TemplateLiteral)}
*/
function canVerify(node) {
return (
node.type === 'Literal' ||
(node.type === 'TemplateLiteral' &&
node.expressions.length === 0 &&
node.quasis.length === 1)
)
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'require component name property to match its file name',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/match-component-file-name.html'
},
fixable: null,
hasSuggestions: true,
schema: [
{
type: 'object',
properties: {
extensions: {
type: 'array',
items: {
type: 'string'
},
uniqueItems: true,
additionalItems: false
},
shouldMatchCase: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
shouldMatchFileName:
'Component name `{{name}}` should match file name `{{filename}}`.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = context.options[0]
const shouldMatchCase = (options && options.shouldMatchCase) || false
const extensionsArray = options && options.extensions
const allowedExtensions = Array.isArray(extensionsArray)
? extensionsArray
: ['jsx']
const extension = path.extname(context.getFilename())
const filename = path.basename(context.getFilename(), extension)
/** @type {Rule.ReportDescriptor[]} */
const errors = []
let componentCount = 0
if (!allowedExtensions.includes(extension.replace(/^\./, ''))) {
return {}
}
/**
* @param {string} name
* @param {string} filename
*/
function compareNames(name, filename) {
if (shouldMatchCase) {
return name === filename
}
return (
casing.pascalCase(name) === filename ||
casing.kebabCase(name) === filename
)
}
/**
* @param {Literal | TemplateLiteral} node
*/
function verifyName(node) {
let name
if (node.type === 'TemplateLiteral') {
const quasis = node.quasis[0]
name = quasis.value.cooked
} else {
name = `${node.value}`
}
if (!compareNames(name, filename)) {
errors.push({
node,
messageId: 'shouldMatchFileName',
data: { filename, name },
suggest: [
{
desc: 'Rename component to match file name.',
fix(fixer) {
const quote =
node.type === 'TemplateLiteral' ? '`' : node.raw[0]
return fixer.replaceText(node, `${quote}${filename}${quote}`)
}
}
]
})
}
}
return utils.compositingVisitors(
utils.executeOnCallVueComponent(context, (node) => {
if (node.arguments.length === 2) {
const argument = node.arguments[0]
if (canVerify(argument)) {
verifyName(argument)
}
}
}),
utils.executeOnVue(context, (object) => {
const node = utils.findProperty(object, 'name')
componentCount++
if (!node) return
if (!canVerify(node.value)) return
verifyName(node.value)
}),
utils.defineScriptSetupVisitor(context, {
onDefineOptionsEnter(node) {
componentCount++
if (node.arguments.length === 0) return
const define = node.arguments[0]
if (define.type !== 'ObjectExpression') return
const nameNode = utils.findProperty(define, 'name')
if (!nameNode) return
if (!canVerify(nameNode.value)) return
verifyName(nameNode.value)
}
}),
{
'Program:exit'() {
if (componentCount > 1) return
for (const error of errors) context.report(error)
}
}
)
}
}

View File

@ -0,0 +1,73 @@
/**
* @author Doug Wade <douglas.b.wade@gmail.com>
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
/**
* @param {Identifier} identifier
* @return {Array<String>}
*/
function getExpectedNames(identifier) {
return [casing.pascalCase(identifier.name), casing.kebabCase(identifier.name)]
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'require the registered component name to match the imported component name',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/match-component-import-name.html'
},
fixable: null,
schema: [],
messages: {
unexpected:
'Component alias {{importedName}} should be one of: {{expectedName}}.'
}
},
/**
* @param {RuleContext} context
* @returns {RuleListener}
*/
create(context) {
return utils.executeOnVueComponent(context, (obj) => {
const components = utils.findProperty(obj, 'components')
if (
!components ||
!components.value ||
components.value.type !== 'ObjectExpression'
) {
return
}
for (const property of components.value.properties) {
if (
property.type === 'SpreadElement' ||
property.value.type !== 'Identifier' ||
property.computed === true
) {
continue
}
const importedName = utils.getStaticPropertyName(property) || ''
const expectedNames = getExpectedNames(property.value)
if (!expectedNames.includes(importedName)) {
context.report({
node: property,
messageId: 'unexpected',
data: {
importedName,
expectedName: expectedNames.join(', ')
}
})
}
}
})
}
}

View File

@ -0,0 +1,183 @@
/**
* @fileoverview Define the number of attributes allows per line
* @author Filipa Lacerda
*/
'use strict'
const utils = require('../utils')
/**
* @param {any} options
*/
function parseOptions(options) {
const defaults = {
singleline: 1,
multiline: 1
}
if (options) {
if (typeof options.singleline === 'number') {
defaults.singleline = options.singleline
} else if (
typeof options.singleline === 'object' &&
typeof options.singleline.max === 'number'
) {
defaults.singleline = options.singleline.max
}
if (options.multiline) {
if (typeof options.multiline === 'number') {
defaults.multiline = options.multiline
} else if (
typeof options.multiline === 'object' &&
typeof options.multiline.max === 'number'
) {
defaults.multiline = options.multiline.max
}
}
}
return defaults
}
/**
* @param {(VDirective | VAttribute)[]} attributes
*/
function groupAttrsByLine(attributes) {
const propsPerLine = [[attributes[0]]]
for (let index = 1; index < attributes.length; index++) {
const previous = attributes[index - 1]
const current = attributes[index]
if (previous.loc.end.line === current.loc.start.line) {
propsPerLine[propsPerLine.length - 1].push(current)
} else {
propsPerLine.push([current])
}
}
return propsPerLine
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce the maximum number of attributes per line',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/max-attributes-per-line.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
singleline: {
anyOf: [
{
type: 'number',
minimum: 1
},
{
type: 'object',
properties: {
max: {
type: 'number',
minimum: 1
}
},
additionalProperties: false
}
]
},
multiline: {
anyOf: [
{
type: 'number',
minimum: 1
},
{
type: 'object',
properties: {
max: {
type: 'number',
minimum: 1
}
},
additionalProperties: false
}
]
}
},
additionalProperties: false
}
],
messages: {
shouldBeOnNewLine: "'{{name}}' should be on a new line."
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const configuration = parseOptions(context.options[0])
const multilineMaximum = configuration.multiline
const singlelinemMaximum = configuration.singleline
const template =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
return utils.defineTemplateBodyVisitor(context, {
VStartTag(node) {
const numberOfAttributes = node.attributes.length
if (!numberOfAttributes) return
if (
utils.isSingleLine(node) &&
numberOfAttributes > singlelinemMaximum
) {
showErrors(node.attributes.slice(singlelinemMaximum))
}
if (!utils.isSingleLine(node)) {
for (const attrs of groupAttrsByLine(node.attributes)) {
if (attrs.length > multilineMaximum) {
showErrors(attrs.splice(multilineMaximum))
}
}
}
}
})
/**
* @param {(VDirective | VAttribute)[]} attributes
*/
function showErrors(attributes) {
for (const [i, prop] of attributes.entries()) {
context.report({
node: prop,
loc: prop.loc,
messageId: 'shouldBeOnNewLine',
data: { name: sourceCode.getText(prop.key) },
fix(fixer) {
if (i !== 0) return null
// Find the closest token before the current prop
// that is not a white space
const prevToken = /** @type {Token} */ (
template.getTokenBefore(prop, {
filter: (token) => token.type !== 'HTMLWhitespace'
})
)
/** @type {Range} */
const range = [prevToken.range[1], prop.range[0]]
return fixer.replaceTextRange(range, '\n')
}
})
}
}
}
}

View File

@ -0,0 +1,516 @@
/**
* @author Yosuke Ota
* @fileoverview Rule to check for max length on a line of Vue file.
*/
'use strict'
const utils = require('../utils')
const OPTIONS_SCHEMA = {
type: 'object',
properties: {
code: {
type: 'integer',
minimum: 0
},
template: {
type: 'integer',
minimum: 0
},
comments: {
type: 'integer',
minimum: 0
},
tabWidth: {
type: 'integer',
minimum: 0
},
ignorePattern: {
type: 'string'
},
ignoreComments: {
type: 'boolean'
},
ignoreTrailingComments: {
type: 'boolean'
},
ignoreUrls: {
type: 'boolean'
},
ignoreStrings: {
type: 'boolean'
},
ignoreTemplateLiterals: {
type: 'boolean'
},
ignoreRegExpLiterals: {
type: 'boolean'
},
ignoreHTMLAttributeValues: {
type: 'boolean'
},
ignoreHTMLTextContents: {
type: 'boolean'
}
},
additionalProperties: false
}
const OPTIONS_OR_INTEGER_SCHEMA = {
anyOf: [
OPTIONS_SCHEMA,
{
type: 'integer',
minimum: 0
}
]
}
/**
* Computes the length of a line that may contain tabs. The width of each
* tab will be the number of spaces to the next tab stop.
* @param {string} line The line.
* @param {number} tabWidth The width of each tab stop in spaces.
* @returns {number} The computed line length.
* @private
*/
function computeLineLength(line, tabWidth) {
let extraCharacterCount = 0
const re = /\t/gu
let ret
while ((ret = re.exec(line))) {
const offset = ret.index
const totalOffset = offset + extraCharacterCount
const previousTabStopOffset = tabWidth ? totalOffset % tabWidth : 0
const spaceCount = tabWidth - previousTabStopOffset
extraCharacterCount += spaceCount - 1 // -1 for the replaced tab
}
return [...line].length + extraCharacterCount
}
/**
* Tells if a given comment is trailing: it starts on the current line and
* extends to or past the end of the current line.
* @param {string} line The source line we want to check for a trailing comment on
* @param {number} lineNumber The one-indexed line number for line
* @param {Token | null} comment The comment to inspect
* @returns {comment is Token} If the comment is trailing on the given line
*/
function isTrailingComment(line, lineNumber, comment) {
return Boolean(
comment &&
comment.loc.start.line === lineNumber &&
lineNumber <= comment.loc.end.line &&
(comment.loc.end.line > lineNumber ||
comment.loc.end.column === line.length)
)
}
/**
* Tells if a comment encompasses the entire line.
* @param {string} line The source line with a trailing comment
* @param {number} lineNumber The one-indexed line number this is on
* @param {Token | null} comment The comment to remove
* @returns {boolean} If the comment covers the entire line
*/
function isFullLineComment(line, lineNumber, comment) {
if (!comment) {
return false
}
const start = comment.loc.start
const end = comment.loc.end
const isFirstTokenOnLine = !line.slice(0, comment.loc.start.column).trim()
return (
comment &&
(start.line < lineNumber ||
(start.line === lineNumber && isFirstTokenOnLine)) &&
(end.line > lineNumber ||
(end.line === lineNumber && end.column === line.length))
)
}
/**
* Gets the line after the comment and any remaining trailing whitespace is
* stripped.
* @param {string} line The source line with a trailing comment
* @param {Token} comment The comment to remove
* @returns {string} Line without comment and trailing whitepace
*/
function stripTrailingComment(line, comment) {
// loc.column is zero-indexed
return line.slice(0, comment.loc.start.column).replace(/\s+$/u, '')
}
/**
* Group AST nodes by line number, both start and end.
*
* @param {Token[]} nodes the AST nodes in question
* @returns { { [key: number]: Token[] } } the grouped nodes
* @private
*/
function groupByLineNumber(nodes) {
/** @type { { [key: number]: Token[] } } */
const grouped = {}
for (const node of nodes) {
for (let i = node.loc.start.line; i <= node.loc.end.line; ++i) {
if (!Array.isArray(grouped[i])) {
grouped[i] = []
}
grouped[i].push(node)
}
}
return grouped
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce a maximum line length in `.vue` files',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/max-len.html',
extensionSource: {
url: 'https://eslint.org/docs/rules/max-len',
name: 'ESLint core'
}
},
schema: [
OPTIONS_OR_INTEGER_SCHEMA,
OPTIONS_OR_INTEGER_SCHEMA,
OPTIONS_SCHEMA
],
messages: {
max: 'This line has a length of {{lineLength}}. Maximum allowed is {{maxLength}}.',
maxComment:
'This line has a comment length of {{lineLength}}. Maximum allowed is {{maxCommentLength}}.'
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
/*
* Inspired by http://tools.ietf.org/html/rfc3986#appendix-B, however:
* - They're matching an entire string that we know is a URI
* - We're matching part of a string where we think there *might* be a URL
* - We're only concerned about URLs, as picking out any URI would cause
* too many false positives
* - We don't care about matching the entire URL, any small segment is fine
*/
const URL_REGEXP = /[^:/?#]:\/\/[^?#]/u
const sourceCode = context.getSourceCode()
/** @type {Token[]} */
const tokens = []
/** @type {(HTMLComment | HTMLBogusComment | Comment)[]} */
const comments = []
/** @type {VLiteral[]} */
const htmlAttributeValues = []
// The options object must be the last option specified…
const options = Object.assign(
{},
context.options[context.options.length - 1]
)
// …but max code length…
if (typeof context.options[0] === 'number') {
options.code = context.options[0]
}
// …and tabWidth can be optionally specified directly as integers.
if (typeof context.options[1] === 'number') {
options.tabWidth = context.options[1]
}
/** @type {number} */
const scriptMaxLength = typeof options.code === 'number' ? options.code : 80
/** @type {number} */
const tabWidth = typeof options.tabWidth === 'number' ? options.tabWidth : 2 // default value of `vue/html-indent`
/** @type {number} */
const templateMaxLength =
typeof options.template === 'number' ? options.template : scriptMaxLength
const ignoreComments = !!options.ignoreComments
const ignoreStrings = !!options.ignoreStrings
const ignoreTemplateLiterals = !!options.ignoreTemplateLiterals
const ignoreRegExpLiterals = !!options.ignoreRegExpLiterals
const ignoreTrailingComments =
!!options.ignoreTrailingComments || !!options.ignoreComments
const ignoreUrls = !!options.ignoreUrls
const ignoreHTMLAttributeValues = !!options.ignoreHTMLAttributeValues
const ignoreHTMLTextContents = !!options.ignoreHTMLTextContents
/** @type {number} */
const maxCommentLength = options.comments
/** @type {RegExp} */
let ignorePattern = options.ignorePattern || null
if (ignorePattern) {
ignorePattern = new RegExp(ignorePattern, 'u')
}
/**
* Retrieves an array containing all strings (" or ') in the source code.
*
* @returns {Token[]} An array of string nodes.
*/
function getAllStrings() {
return tokens.filter(
(token) =>
token.type === 'String' ||
(token.type === 'JSXText' &&
sourceCode.getNodeByRangeIndex(token.range[0] - 1).type ===
'JSXAttribute')
)
}
/**
* Retrieves an array containing all template literals in the source code.
*
* @returns {Token[]} An array of template literal nodes.
*/
function getAllTemplateLiterals() {
return tokens.filter((token) => token.type === 'Template')
}
/**
* Retrieves an array containing all RegExp literals in the source code.
*
* @returns {Token[]} An array of RegExp literal nodes.
*/
function getAllRegExpLiterals() {
return tokens.filter((token) => token.type === 'RegularExpression')
}
/**
* Retrieves an array containing all HTML texts in the source code.
*
* @returns {Token[]} An array of HTML text nodes.
*/
function getAllHTMLTextContents() {
return tokens.filter((token) => token.type === 'HTMLText')
}
/**
* Check the program for max length
* @param {Program} node Node to examine
* @returns {void}
* @private
*/
function checkProgramForMaxLength(node) {
const programNode = node
const templateBody = node.templateBody
// setup tokens
const scriptTokens = sourceCode.ast.tokens
const scriptComments = sourceCode.getAllComments()
if (sourceCode.parserServices.getTemplateBodyTokenStore && templateBody) {
const tokenStore = sourceCode.parserServices.getTemplateBodyTokenStore()
const templateTokens = tokenStore.getTokens(templateBody, {
includeComments: true
})
if (templateBody.range[0] < programNode.range[0]) {
tokens.push(...templateTokens, ...scriptTokens)
} else {
tokens.push(...scriptTokens, ...templateTokens)
}
} else {
tokens.push(...scriptTokens)
}
if (ignoreComments || maxCommentLength || ignoreTrailingComments) {
// list of comments to ignore
if (templateBody) {
if (templateBody.range[0] < programNode.range[0]) {
comments.push(...templateBody.comments, ...scriptComments)
} else {
comments.push(...scriptComments, ...templateBody.comments)
}
} else {
comments.push(...scriptComments)
}
}
/** @type {Range | undefined} */
let scriptLinesRange
if (scriptTokens.length > 0) {
scriptLinesRange =
scriptComments.length > 0
? [
Math.min(
scriptTokens[0].loc.start.line,
scriptComments[0].loc.start.line
),
Math.max(
scriptTokens[scriptTokens.length - 1].loc.end.line,
scriptComments[scriptComments.length - 1].loc.end.line
)
]
: [
scriptTokens[0].loc.start.line,
scriptTokens[scriptTokens.length - 1].loc.end.line
]
} else if (scriptComments.length > 0) {
scriptLinesRange = [
scriptComments[0].loc.start.line,
scriptComments[scriptComments.length - 1].loc.end.line
]
}
const templateLinesRange = templateBody && [
templateBody.loc.start.line,
templateBody.loc.end.line
]
// split (honors line-ending)
const lines = sourceCode.lines
const strings = getAllStrings()
const stringsByLine = groupByLineNumber(strings)
const templateLiterals = getAllTemplateLiterals()
const templateLiteralsByLine = groupByLineNumber(templateLiterals)
const regExpLiterals = getAllRegExpLiterals()
const regExpLiteralsByLine = groupByLineNumber(regExpLiterals)
const htmlAttributeValuesByLine = groupByLineNumber(htmlAttributeValues)
const htmlTextContents = getAllHTMLTextContents()
const htmlTextContentsByLine = groupByLineNumber(htmlTextContents)
const commentsByLine = groupByLineNumber(comments)
for (const [i, line] of lines.entries()) {
// i is zero-indexed, line numbers are one-indexed
const lineNumber = i + 1
const inScript =
scriptLinesRange &&
scriptLinesRange[0] <= lineNumber &&
lineNumber <= scriptLinesRange[1]
const inTemplate =
templateLinesRange &&
templateLinesRange[0] <= lineNumber &&
lineNumber <= templateLinesRange[1]
// check if line is inside a script or template.
if (!inScript && !inTemplate) {
// out of range.
continue
}
const maxLength = Math.max(
inScript ? scriptMaxLength : 0,
inTemplate ? templateMaxLength : 0
)
if (
(ignoreStrings && stringsByLine[lineNumber]) ||
(ignoreTemplateLiterals && templateLiteralsByLine[lineNumber]) ||
(ignoreRegExpLiterals && regExpLiteralsByLine[lineNumber]) ||
(ignoreHTMLAttributeValues &&
htmlAttributeValuesByLine[lineNumber]) ||
(ignoreHTMLTextContents && htmlTextContentsByLine[lineNumber])
) {
// ignore this line
continue
}
/*
* if we're checking comment length; we need to know whether this
* line is a comment
*/
let lineIsComment = false
let textToMeasure
/*
* comments to check.
*/
if (commentsByLine[lineNumber]) {
const commentList = [...commentsByLine[lineNumber]]
let comment = commentList.pop() || null
if (isFullLineComment(line, lineNumber, comment)) {
lineIsComment = true
textToMeasure = line
} else if (
ignoreTrailingComments &&
isTrailingComment(line, lineNumber, comment)
) {
textToMeasure = stripTrailingComment(line, comment)
// ignore multiple trailing comments in the same line
comment = commentList.pop() || null
while (isTrailingComment(textToMeasure, lineNumber, comment)) {
textToMeasure = stripTrailingComment(textToMeasure, comment)
}
} else {
textToMeasure = line
}
} else {
textToMeasure = line
}
if (
(ignorePattern && ignorePattern.test(textToMeasure)) ||
(ignoreUrls && URL_REGEXP.test(textToMeasure))
) {
// ignore this line
continue
}
const lineLength = computeLineLength(textToMeasure, tabWidth)
const commentLengthApplies = lineIsComment && maxCommentLength
if (lineIsComment && ignoreComments) {
continue
}
if (commentLengthApplies) {
if (lineLength > maxCommentLength) {
context.report({
node,
loc: { line: lineNumber, column: 0 },
messageId: 'maxComment',
data: {
lineLength,
maxCommentLength
}
})
}
} else if (lineLength > maxLength) {
context.report({
node,
loc: { line: lineNumber, column: 0 },
messageId: 'max',
data: {
lineLength,
maxLength
}
})
}
}
}
return utils.compositingVisitors(
utils.defineTemplateBodyVisitor(context, {
/** @param {VLiteral} node */
'VAttribute[directive=false] > VLiteral'(node) {
htmlAttributeValues.push(node)
}
}),
{
'Program:exit'(node) {
checkProgramForMaxLength(node)
}
}
)
}
}

View File

@ -0,0 +1,111 @@
/**
* @author lsdsjy
* @fileoverview Rule for checking the maximum number of lines in Vue SFC blocks.
*/
'use strict'
const { SourceCode } = require('eslint')
const utils = require('../utils')
/**
* @param {string} text
*/
function isEmptyLine(text) {
return !text.trim()
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'enforce maximum number of lines in Vue SFC blocks',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/max-lines-per-block.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
style: {
type: 'integer',
minimum: 1
},
template: {
type: 'integer',
minimum: 1
},
script: {
type: 'integer',
minimum: 1
},
skipBlankLines: {
type: 'boolean',
minimum: 0
}
},
additionalProperties: false
}
],
messages: {
tooManyLines:
'Block has too many lines ({{lineCount}}). Maximum allowed is {{limit}}.'
}
},
/** @param {RuleContext} context */
create(context) {
const option = context.options[0] || {}
/**
* @type {Record<string, number>}
*/
const limits = {
template: option.template,
script: option.script,
style: option.style
}
const code = context.getSourceCode()
const sourceCode = context.getSourceCode()
const documentFragment =
sourceCode.parserServices.getDocumentFragment &&
sourceCode.parserServices.getDocumentFragment()
function getTopLevelHTMLElements() {
if (documentFragment) {
return documentFragment.children.filter(utils.isVElement)
}
return []
}
return {
/** @param {Program} node */
Program(node) {
if (utils.hasInvalidEOF(node)) {
return
}
for (const block of getTopLevelHTMLElements()) {
if (limits[block.name]) {
// We suppose the start tag and end tag occupy one single line respectively
let lineCount = block.loc.end.line - block.loc.start.line - 1
if (option.skipBlankLines) {
const lines = SourceCode.splitLines(code.getText(block))
lineCount -= lines.filter(isEmptyLine).length
}
if (lineCount > limits[block.name]) {
context.report({
node: block,
messageId: 'tooManyLines',
data: {
limit: limits[block.name],
lineCount
}
})
}
}
}
}
}
}
}

View File

@ -0,0 +1,135 @@
/**
* @author Marton Csordas
* See LICENSE file in root directory for full license.
*/
'use strict'
const path = require('path')
const casing = require('../utils/casing')
const utils = require('../utils')
const RESERVED_NAMES_IN_VUE3 = new Set(
require('../utils/vue3-builtin-components')
)
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'require component names to be always multi-word',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/multi-word-component-names.html'
},
schema: [
{
type: 'object',
properties: {
ignores: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
additionalItems: false
}
},
additionalProperties: false
}
],
messages: {
unexpected: 'Component name "{{value}}" should always be multi-word.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @type {Set<string>} */
const ignores = new Set()
ignores.add('App')
ignores.add('app')
for (const ignore of (context.options[0] && context.options[0].ignores) ||
[]) {
ignores.add(ignore)
if (casing.isPascalCase(ignore)) {
// PascalCase
ignores.add(casing.kebabCase(ignore))
}
}
let hasVue = utils.isScriptSetup(context)
let hasName = false
/**
* Returns true if the given component name is valid, otherwise false.
* @param {string} name
* */
function isValidComponentName(name) {
if (ignores.has(name) || RESERVED_NAMES_IN_VUE3.has(name)) {
return true
}
const elements = casing.kebabCase(name).split('-')
return elements.length > 1
}
/**
* @param {Expression | SpreadElement} nameNode
*/
function validateName(nameNode) {
if (nameNode.type !== 'Literal') return
const componentName = `${nameNode.value}`
if (!isValidComponentName(componentName)) {
context.report({
node: nameNode,
messageId: 'unexpected',
data: {
value: componentName
}
})
}
}
return utils.compositingVisitors(
utils.executeOnCallVueComponent(context, (node) => {
hasVue = true
if (node.arguments.length !== 2) return
hasName = true
validateName(node.arguments[0])
}),
utils.executeOnVue(context, (obj) => {
hasVue = true
const node = utils.findProperty(obj, 'name')
if (!node) return
hasName = true
validateName(node.value)
}),
utils.defineScriptSetupVisitor(context, {
onDefineOptionsEnter(node) {
if (node.arguments.length === 0) return
const define = node.arguments[0]
if (define.type !== 'ObjectExpression') return
const nameNode = utils.findProperty(define, 'name')
if (!nameNode) return
hasName = true
validateName(nameNode.value)
}
}),
{
/** @param {Program} node */
'Program:exit'(node) {
if (hasName) return
if (!hasVue && node.body.length > 0) return
const fileName = context.getFilename()
const componentName = path.basename(fileName, path.extname(fileName))
if (
utils.isVueFile(fileName) &&
!isValidComponentName(componentName)
) {
context.report({
messageId: 'unexpected',
data: {
value: componentName
},
loc: { line: 1, column: 0 }
})
}
}
}
)
}
}

View File

@ -0,0 +1,229 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
const INLINE_ELEMENTS = require('../utils/inline-non-void-elements.json')
/**
* @param {VElement & { endTag: VEndTag }} element
*/
function isMultilineElement(element) {
return element.loc.start.line < element.endTag.loc.start.line
}
/**
* @param {any} options
*/
function parseOptions(options) {
return Object.assign(
{
ignores: ['pre', 'textarea', ...INLINE_ELEMENTS],
ignoreWhenEmpty: true,
allowEmptyLines: false
},
options
)
}
/**
* @param {number} lineBreaks
*/
function getPhrase(lineBreaks) {
switch (lineBreaks) {
case 0: {
return 'no'
}
default: {
return `${lineBreaks}`
}
}
}
/**
* Check whether the given element is empty or not.
* This ignores whitespaces, doesn't ignore comments.
* @param {VElement & { endTag: VEndTag }} node The element node to check.
* @param {SourceCode} sourceCode The source code object of the current context.
* @returns {boolean} `true` if the element is empty.
*/
function isEmpty(node, sourceCode) {
const start = node.startTag.range[1]
const end = node.endTag.range[0]
return sourceCode.text.slice(start, end).trim() === ''
}
module.exports = {
meta: {
type: 'layout',
docs: {
description:
'require a line break before and after the contents of a multiline element',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/multiline-html-element-content-newline.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
ignoreWhenEmpty: {
type: 'boolean'
},
ignores: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
additionalItems: false
},
allowEmptyLines: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
unexpectedAfterClosingBracket:
'Expected 1 line break after opening tag (`<{{name}}>`), but {{actual}} line breaks found.',
unexpectedBeforeOpeningBracket:
'Expected 1 line break before closing tag (`</{{name}}>`), but {{actual}} line breaks found.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = parseOptions(context.options[0])
const ignores = options.ignores
const ignoreWhenEmpty = options.ignoreWhenEmpty
const allowEmptyLines = options.allowEmptyLines
const sourceCode = context.getSourceCode()
const template =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
/** @type {VElement | null} */
let inIgnoreElement = null
/**
* @param {VElement} node
*/
function isIgnoredElement(node) {
return (
ignores.includes(node.name) ||
ignores.includes(casing.pascalCase(node.rawName)) ||
ignores.includes(casing.kebabCase(node.rawName))
)
}
/**
* @param {number} lineBreaks
*/
function isInvalidLineBreaks(lineBreaks) {
return allowEmptyLines ? lineBreaks === 0 : lineBreaks !== 1
}
return utils.defineTemplateBodyVisitor(context, {
VElement(node) {
if (inIgnoreElement) {
return
}
if (isIgnoredElement(node)) {
// ignore element name
inIgnoreElement = node
return
}
if (node.startTag.selfClosing || !node.endTag) {
// self closing
return
}
const element = /** @type {VElement & { endTag: VEndTag }} */ (node)
if (!isMultilineElement(element)) {
return
}
/**
* @type {SourceCode.CursorWithCountOptions}
*/
const getTokenOption = {
includeComments: true,
filter: (token) => token.type !== 'HTMLWhitespace'
}
if (
ignoreWhenEmpty &&
element.children.length === 0 &&
template.getFirstTokensBetween(
element.startTag,
element.endTag,
getTokenOption
).length === 0
) {
return
}
const contentFirst = /** @type {Token} */ (
template.getTokenAfter(element.startTag, getTokenOption)
)
const contentLast = /** @type {Token} */ (
template.getTokenBefore(element.endTag, getTokenOption)
)
const beforeLineBreaks =
contentFirst.loc.start.line - element.startTag.loc.end.line
const afterLineBreaks =
element.endTag.loc.start.line - contentLast.loc.end.line
if (isInvalidLineBreaks(beforeLineBreaks)) {
context.report({
node: template.getLastToken(element.startTag),
loc: {
start: element.startTag.loc.end,
end: contentFirst.loc.start
},
messageId: 'unexpectedAfterClosingBracket',
data: {
name: element.rawName,
actual: getPhrase(beforeLineBreaks)
},
fix(fixer) {
/** @type {Range} */
const range = [element.startTag.range[1], contentFirst.range[0]]
return fixer.replaceTextRange(range, '\n')
}
})
}
if (isEmpty(element, sourceCode)) {
return
}
if (isInvalidLineBreaks(afterLineBreaks)) {
context.report({
node: template.getFirstToken(element.endTag),
loc: {
start: contentLast.loc.end,
end: element.endTag.loc.start
},
messageId: 'unexpectedBeforeOpeningBracket',
data: {
name: element.name,
actual: getPhrase(afterLineBreaks)
},
fix(fixer) {
/** @type {Range} */
const range = [contentLast.range[1], element.endTag.range[0]]
return fixer.replaceTextRange(range, '\n')
}
})
}
},
'VElement:exit'(node) {
if (inIgnoreElement === node) {
inIgnoreElement = null
}
}
})
}
}

View File

@ -0,0 +1,13 @@
/**
* @author dev1437
* See LICENSE file in root directory for full license.
*/
'use strict'
const { wrapStylisticOrCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('multiline-ternary', {
skipDynamicArguments: true,
applyDocument: true
})

View File

@ -0,0 +1,102 @@
/**
* @fileoverview enforce unified spacing in mustache interpolations.
* @author Armano
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce unified spacing in mustache interpolations',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/mustache-interpolation-spacing.html'
},
fixable: 'whitespace',
schema: [
{
enum: ['always', 'never']
}
],
messages: {
expectedSpaceAfter: "Expected 1 space after '{{', but not found.",
expectedSpaceBefore: "Expected 1 space before '}}', but not found.",
unexpectedSpaceAfter: "Expected no space after '{{', but found.",
unexpectedSpaceBefore: "Expected no space before '}}', but found."
}
},
/** @param {RuleContext} context */
create(context) {
const options = context.options[0] || 'always'
const sourceCode = context.getSourceCode()
const template =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
return utils.defineTemplateBodyVisitor(context, {
/** @param {VExpressionContainer} node */
'VExpressionContainer[expression!=null]'(node) {
const openBrace = template.getFirstToken(node)
const closeBrace = template.getLastToken(node)
if (
!openBrace ||
!closeBrace ||
openBrace.type !== 'VExpressionStart' ||
closeBrace.type !== 'VExpressionEnd'
) {
return
}
const firstToken = template.getTokenAfter(openBrace, {
includeComments: true
})
const lastToken = template.getTokenBefore(closeBrace, {
includeComments: true
})
if (options === 'always') {
if (openBrace.range[1] === firstToken.range[0]) {
context.report({
node: openBrace,
messageId: 'expectedSpaceAfter',
fix: (fixer) => fixer.insertTextAfter(openBrace, ' ')
})
}
if (closeBrace.range[0] === lastToken.range[1]) {
context.report({
node: closeBrace,
messageId: 'expectedSpaceBefore',
fix: (fixer) => fixer.insertTextBefore(closeBrace, ' ')
})
}
} else {
if (openBrace.range[1] !== firstToken.range[0]) {
context.report({
loc: {
start: openBrace.loc.start,
end: firstToken.loc.start
},
messageId: 'unexpectedSpaceAfter',
fix: (fixer) =>
fixer.removeRange([openBrace.range[1], firstToken.range[0]])
})
}
if (closeBrace.range[0] !== lastToken.range[1]) {
context.report({
loc: {
start: lastToken.loc.end,
end: closeBrace.loc.end
},
messageId: 'unexpectedSpaceBefore',
fix: (fixer) =>
fixer.removeRange([lastToken.range[1], closeBrace.range[0]])
})
}
}
}
})
}
}

View File

@ -0,0 +1,154 @@
/**
* @fileoverview Enforce new lines between multi-line properties in Vue components.
* @author IWANABETHATGUY
*/
'use strict'
const utils = require('../utils')
/**
* @param {Token} node
*/
function isComma(node) {
return node.type === 'Punctuator' && node.value === ','
}
/**
* Check whether the between given nodes has empty line.
* @param {SourceCode} sourceCode
* @param {ASTNode} pre
* @param {ASTNode} cur
*/
function* iterateBetweenTokens(sourceCode, pre, cur) {
yield sourceCode.getLastToken(pre)
yield* sourceCode.getTokensBetween(pre, cur, {
includeComments: true
})
yield sourceCode.getFirstToken(cur)
}
/**
* Check whether the between given nodes has empty line.
* @param {SourceCode} sourceCode
* @param {ASTNode} pre
* @param {ASTNode} cur
*/
function hasEmptyLine(sourceCode, pre, cur) {
/** @type {Token|null} */
let preToken = null
for (const token of iterateBetweenTokens(sourceCode, pre, cur)) {
if (preToken && token.loc.start.line - preToken.loc.end.line >= 2) {
return true
}
preToken = token
}
return false
}
module.exports = {
meta: {
type: 'layout',
docs: {
description:
'enforce new lines between multi-line properties in Vue components',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/new-line-between-multi-line-property.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
// number of line you want to insert after multi-line property
minLineOfMultilineProperty: {
type: 'number',
minimum: 2
}
},
additionalProperties: false
}
],
messages: {
missingEmptyLine:
'Enforce new lines between multi-line properties in Vue components.'
}
},
/** @param {RuleContext} context */
create(context) {
let minLineOfMultilineProperty = 2
if (
context.options &&
context.options[0] &&
context.options[0].minLineOfMultilineProperty
) {
minLineOfMultilineProperty = context.options[0].minLineOfMultilineProperty
}
/** @type {CallExpression[]} */
const callStack = []
const sourceCode = context.getSourceCode()
return Object.assign(
utils.defineVueVisitor(context, {
CallExpression(node) {
callStack.push(node)
},
'CallExpression:exit'() {
callStack.pop()
},
/**
* @param {ObjectExpression} node
*/
ObjectExpression(node) {
if (callStack.length > 0) {
return
}
const properties = node.properties
for (let i = 1; i < properties.length; i++) {
const cur = properties[i]
const pre = properties[i - 1]
const lineCountOfPreProperty =
pre.loc.end.line - pre.loc.start.line + 1
if (lineCountOfPreProperty < minLineOfMultilineProperty) {
continue
}
if (hasEmptyLine(sourceCode, pre, cur)) {
continue
}
context.report({
node: pre,
loc: {
start: pre.loc.end,
end: cur.loc.start
},
messageId: 'missingEmptyLine',
fix(fixer) {
/** @type {Token|null} */
let preToken = null
for (const token of iterateBetweenTokens(
sourceCode,
pre,
cur
)) {
if (
preToken &&
preToken.loc.end.line < token.loc.start.line
) {
return fixer.insertTextAfter(preToken, '\n')
}
preToken = token
}
const commaToken = sourceCode.getTokenAfter(pre, isComma)
return fixer.insertTextAfter(commaToken || pre, '\n\n')
}
})
}
}
})
)
}
}

View File

@ -0,0 +1,141 @@
/**
* @fileoverview enforce Promise or callback style in `nextTick`
* @author Flo Edelmann
* @copyright 2020 Flo Edelmann. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const { findVariable } = require('@eslint-community/eslint-utils')
/**
* @param {Identifier} identifier
* @param {RuleContext} context
* @returns {CallExpression|undefined}
*/
function getVueNextTickCallExpression(identifier, context) {
// Instance API: this.$nextTick()
if (
identifier.name === '$nextTick' &&
identifier.parent.type === 'MemberExpression' &&
utils.isThis(identifier.parent.object, context) &&
identifier.parent.parent.type === 'CallExpression' &&
identifier.parent.parent.callee === identifier.parent
) {
return identifier.parent.parent
}
// Vue 2 Global API: Vue.nextTick()
if (
identifier.name === 'nextTick' &&
identifier.parent.type === 'MemberExpression' &&
identifier.parent.object.type === 'Identifier' &&
identifier.parent.object.name === 'Vue' &&
identifier.parent.parent.type === 'CallExpression' &&
identifier.parent.parent.callee === identifier.parent
) {
return identifier.parent.parent
}
// Vue 3 Global API: import { nextTick as nt } from 'vue'; nt()
if (
identifier.parent.type === 'CallExpression' &&
identifier.parent.callee === identifier
) {
const variable = findVariable(
utils.getScope(context, identifier),
identifier
)
if (variable != null && variable.defs.length === 1) {
const def = variable.defs[0]
if (
def.type === 'ImportBinding' &&
def.node.type === 'ImportSpecifier' &&
def.node.imported.type === 'Identifier' &&
def.node.imported.name === 'nextTick' &&
def.node.parent.type === 'ImportDeclaration' &&
def.node.parent.source.value === 'vue'
) {
return identifier.parent
}
}
}
return undefined
}
/**
* @param {CallExpression} callExpression
* @returns {boolean}
*/
function isAwaitedPromise(callExpression) {
return (
callExpression.parent.type === 'AwaitExpression' ||
(callExpression.parent.type === 'MemberExpression' &&
callExpression.parent.property.type === 'Identifier' &&
callExpression.parent.property.name === 'then')
)
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'enforce Promise or callback style in `nextTick`',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/next-tick-style.html'
},
fixable: 'code',
schema: [{ enum: ['promise', 'callback'] }],
messages: {
usePromise:
'Use the Promise returned by `nextTick` instead of passing a callback function.',
useCallback:
'Pass a callback function to `nextTick` instead of using the returned Promise.'
}
},
/** @param {RuleContext} context */
create(context) {
const preferredStyle =
/** @type {string|undefined} */ (context.options[0]) || 'promise'
return utils.defineVueVisitor(context, {
/** @param {Identifier} node */
Identifier(node) {
const callExpression = getVueNextTickCallExpression(node, context)
if (!callExpression) {
return
}
if (preferredStyle === 'callback') {
if (
callExpression.arguments.length !== 1 ||
isAwaitedPromise(callExpression)
) {
context.report({
node,
messageId: 'useCallback'
})
}
return
}
if (
callExpression.arguments.length > 0 ||
!isAwaitedPromise(callExpression)
) {
context.report({
node,
messageId: 'usePromise',
fix(fixer) {
return fixer.insertTextAfter(node, '().then')
}
})
}
}
})
}
}

View File

@ -0,0 +1,50 @@
/**
* @author Sosuke Suzuki
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow using arrow functions to define watcher',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-arrow-functions-in-watch.html'
},
fixable: null,
schema: [],
messages: {
noArrowFunctionsInWatch:
'You should not use an arrow function to define a watcher.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.executeOnVue(context, (obj) => {
const watchNode = utils.findProperty(obj, 'watch')
if (watchNode == null) {
return
}
const watchValue = watchNode.value
if (watchValue.type !== 'ObjectExpression') {
return
}
for (const property of watchValue.properties) {
if (property.type !== 'Property') {
continue
}
for (const handler of utils.iterateWatchHandlerValues(property)) {
if (handler.type === 'ArrowFunctionExpression') {
context.report({
node: handler,
messageId: 'noArrowFunctionsInWatch'
})
}
}
}
})
}
}

View File

@ -0,0 +1,295 @@
/**
* @fileoverview Check if there are no asynchronous actions inside computed properties.
* @author Armano
*/
'use strict'
const { ReferenceTracker } = require('@eslint-community/eslint-utils')
const utils = require('../utils')
/**
* @typedef {import('../utils').VueObjectData} VueObjectData
* @typedef {import('../utils').VueVisitor} VueVisitor
* @typedef {import('../utils').ComponentComputedProperty} ComponentComputedProperty
*/
const PROMISE_FUNCTIONS = new Set(['then', 'catch', 'finally'])
const PROMISE_METHODS = new Set(['all', 'race', 'reject', 'resolve'])
const TIMED_FUNCTIONS = new Set([
'setTimeout',
'setInterval',
'setImmediate',
'requestAnimationFrame'
])
/**
* @param {CallExpression} node
*/
function isTimedFunction(node) {
const callee = utils.skipChainExpression(node.callee)
return (
((callee.type === 'Identifier' && TIMED_FUNCTIONS.has(callee.name)) ||
(callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'window' &&
TIMED_FUNCTIONS.has(utils.getStaticPropertyName(callee) || ''))) &&
node.arguments.length > 0
)
}
/**
* @param {CallExpression} node
*/
function isPromise(node) {
const callee = utils.skipChainExpression(node.callee)
if (callee.type === 'MemberExpression') {
const name = utils.getStaticPropertyName(callee)
return (
name &&
// hello.PROMISE_FUNCTION()
(PROMISE_FUNCTIONS.has(name) ||
// Promise.PROMISE_METHOD()
(callee.object.type === 'Identifier' &&
callee.object.name === 'Promise' &&
PROMISE_METHODS.has(name)))
)
}
return false
}
/**
* @param {CallExpression} node
* @param {RuleContext} context
*/
function isNextTick(node, context) {
const callee = utils.skipChainExpression(node.callee)
if (callee.type === 'MemberExpression') {
const name = utils.getStaticPropertyName(callee)
return (
(utils.isThis(callee.object, context) && name === '$nextTick') ||
(callee.object.type === 'Identifier' &&
callee.object.name === 'Vue' &&
name === 'nextTick')
)
}
return false
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow asynchronous actions in computed properties',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-async-in-computed-properties.html'
},
fixable: null,
schema: [],
messages: {
unexpectedInFunction:
'Unexpected {{expressionName}} in computed function.',
unexpectedInProperty:
'Unexpected {{expressionName}} in "{{propertyName}}" computed property.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @type {Map<ObjectExpression, ComponentComputedProperty[]>} */
const computedPropertiesMap = new Map()
/** @type {(FunctionExpression | ArrowFunctionExpression)[]} */
const computedFunctionNodes = []
/**
* @typedef {object} ScopeStack
* @property {ScopeStack | null} upper
* @property {BlockStatement | Expression} body
*/
/** @type {ScopeStack | null} */
let scopeStack = null
const expressionTypes = {
promise: 'asynchronous action',
nextTick: 'asynchronous action',
await: 'await operator',
async: 'async function declaration',
new: 'Promise object',
timed: 'timed function'
}
/**
* @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
* @param {VueObjectData|undefined} [info]
*/
function onFunctionEnter(node, info) {
if (node.async) {
verify(
node,
node.body,
'async',
info ? computedPropertiesMap.get(info.node) : null
)
}
scopeStack = {
upper: scopeStack,
body: node.body
}
}
function onFunctionExit() {
scopeStack = scopeStack && scopeStack.upper
}
/**
* @param {ESNode} node
* @param {BlockStatement | Expression} targetBody
* @param {keyof expressionTypes} type
* @param {ComponentComputedProperty[]|undefined|null} computedProperties
*/
function verify(node, targetBody, type, computedProperties) {
for (const cp of computedProperties || []) {
if (
cp.value &&
node.loc.start.line >= cp.value.loc.start.line &&
node.loc.end.line <= cp.value.loc.end.line &&
targetBody === cp.value
) {
context.report({
node,
messageId: 'unexpectedInProperty',
data: {
expressionName: expressionTypes[type],
propertyName: cp.key || 'unknown'
}
})
return
}
}
for (const cf of computedFunctionNodes) {
if (
node.loc.start.line >= cf.body.loc.start.line &&
node.loc.end.line <= cf.body.loc.end.line &&
targetBody === cf.body
) {
context.report({
node,
messageId: 'unexpectedInFunction',
data: {
expressionName: expressionTypes[type]
}
})
return
}
}
}
const nodeVisitor = {
':function': onFunctionEnter,
':function:exit': onFunctionExit,
/**
* @param {NewExpression} node
* @param {VueObjectData|undefined} [info]
*/
NewExpression(node, info) {
if (!scopeStack) {
return
}
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'Promise'
) {
verify(
node,
scopeStack.body,
'new',
info ? computedPropertiesMap.get(info.node) : null
)
}
},
/**
* @param {CallExpression} node
* @param {VueObjectData|undefined} [info]
*/
CallExpression(node, info) {
if (!scopeStack) {
return
}
if (isPromise(node)) {
verify(
node,
scopeStack.body,
'promise',
info ? computedPropertiesMap.get(info.node) : null
)
} else if (isTimedFunction(node)) {
verify(
node,
scopeStack.body,
'timed',
info ? computedPropertiesMap.get(info.node) : null
)
} else if (isNextTick(node, context)) {
verify(
node,
scopeStack.body,
'nextTick',
info ? computedPropertiesMap.get(info.node) : null
)
}
},
/**
* @param {AwaitExpression} node
* @param {VueObjectData|undefined} [info]
*/
AwaitExpression(node, info) {
if (!scopeStack) {
return
}
verify(
node,
scopeStack.body,
'await',
info ? computedPropertiesMap.get(info.node) : null
)
}
}
return utils.compositingVisitors(
{
/** @param {Program} program */
Program(program) {
const tracker = new ReferenceTracker(utils.getScope(context, program))
const traceMap = utils.createCompositionApiTraceMap({
[ReferenceTracker.ESM]: true,
computed: {
[ReferenceTracker.CALL]: true
}
})
for (const { node } of tracker.iterateEsmReferences(traceMap)) {
if (node.type !== 'CallExpression') {
continue
}
const getter = utils.getGetterBodyFromComputedFunction(node)
if (getter) {
computedFunctionNodes.push(getter)
}
}
}
},
utils.isScriptSetup(context)
? utils.defineScriptSetupVisitor(context, nodeVisitor)
: utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
computedPropertiesMap.set(node, utils.getComputedProperties(node))
},
...nodeVisitor
})
)
}
}

View File

@ -0,0 +1,260 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const regexp = require('../utils/regexp')
const casing = require('../utils/casing')
/**
* @typedef { { names: { [tagName in string]: Set<string> }, regexps: { name: RegExp, attrs: Set<string> }[], cache: { [tagName in string]: Set<string> } } } TargetAttrs
*/
// https://dev.w3.org/html5/html-author/charref
const DEFAULT_ALLOWLIST = [
'(',
')',
',',
'.',
'&',
'+',
'-',
'=',
'*',
'/',
'#',
'%',
'!',
'?',
':',
'[',
']',
'{',
'}',
'<',
'>',
'\u00B7', // "·"
'\u2022', // "•"
'\u2010', // ""
'\u2013', // ""
'\u2014', // "—"
'\u2212', // ""
'|'
]
const DEFAULT_ATTRIBUTES = {
'/.+/': [
'title',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext'
],
input: ['placeholder'],
img: ['alt']
}
const DEFAULT_DIRECTIVES = ['v-text']
/**
* Parse attributes option
* @param {any} options
* @returns {TargetAttrs}
*/
function parseTargetAttrs(options) {
/** @type {TargetAttrs} */
const result = { names: {}, regexps: [], cache: {} }
for (const tagName of Object.keys(options)) {
/** @type { Set<string> } */
const attrs = new Set(options[tagName])
if (regexp.isRegExp(tagName)) {
result.regexps.push({
name: regexp.toRegExp(tagName),
attrs
})
} else {
result.names[tagName] = attrs
}
}
return result
}
/**
* Get a string from given expression container node
* @param {VExpressionContainer} value
* @returns { string | null }
*/
function getStringValue(value) {
const expression = value.expression
if (!expression) {
return null
}
if (expression.type !== 'Literal') {
return null
}
if (typeof expression.value === 'string') {
return expression.value
}
return null
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow the use of bare strings in `<template>`',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-bare-strings-in-template.html'
},
schema: [
{
type: 'object',
properties: {
allowlist: {
type: 'array',
items: { type: 'string' },
uniqueItems: true
},
attributes: {
type: 'object',
patternProperties: {
'^(?:\\S+|/.*/[a-z]*)$': {
type: 'array',
items: { type: 'string' },
uniqueItems: true
}
},
additionalProperties: false
},
directives: {
type: 'array',
items: { type: 'string', pattern: '^v-' },
uniqueItems: true
}
},
additionalProperties: false
}
],
messages: {
unexpected: 'Unexpected non-translated string used.',
unexpectedInAttr: 'Unexpected non-translated string used in `{{attr}}`.'
}
},
/** @param {RuleContext} context */
create(context) {
/**
* @typedef { { upper: ElementStack | null, name: string, attrs: Set<string> } } ElementStack
*/
const opts = context.options[0] || {}
/** @type {string[]} */
const allowlist = opts.allowlist || DEFAULT_ALLOWLIST
const attributes = parseTargetAttrs(opts.attributes || DEFAULT_ATTRIBUTES)
const directives = opts.directives || DEFAULT_DIRECTIVES
const allowlistRe = new RegExp(
allowlist.map((w) => regexp.escape(w)).join('|'),
'gu'
)
/** @type {ElementStack | null} */
let elementStack = null
/**
* Gets the bare string from given string
* @param {string} str
*/
function getBareString(str) {
return str.trim().replace(allowlistRe, '').trim()
}
/**
* Get the attribute to be verified from the element name.
* @param {string} tagName
* @returns {Set<string>}
*/
function getTargetAttrs(tagName) {
if (attributes.cache[tagName]) {
return attributes.cache[tagName]
}
/** @type {string[]} */
const result = []
if (attributes.names[tagName]) {
result.push(...attributes.names[tagName])
}
for (const { name, attrs } of attributes.regexps) {
name.lastIndex = 0
if (name.test(tagName)) {
result.push(...attrs)
}
}
if (casing.isKebabCase(tagName)) {
result.push(...getTargetAttrs(casing.pascalCase(tagName)))
}
return (attributes.cache[tagName] = new Set(result))
}
return utils.defineTemplateBodyVisitor(context, {
/** @param {VText} node */
VText(node) {
if (getBareString(node.value)) {
context.report({
node,
messageId: 'unexpected'
})
}
},
/**
* @param {VElement} node
*/
VElement(node) {
elementStack = {
upper: elementStack,
name: node.rawName,
attrs: getTargetAttrs(node.rawName)
}
},
'VElement:exit'() {
elementStack = elementStack && elementStack.upper
},
/** @param {VAttribute|VDirective} node */
VAttribute(node) {
if (!node.value || !elementStack) {
return
}
if (node.directive === false) {
const attrs = elementStack.attrs
if (!attrs.has(node.key.rawName)) {
return
}
if (getBareString(node.value.value)) {
context.report({
node: node.value,
messageId: 'unexpectedInAttr',
data: {
attr: node.key.rawName
}
})
}
} else {
const directive = `v-${node.key.name.name}`
if (!directives.includes(directive)) {
return
}
const str = getStringValue(node.value)
if (str && getBareString(str)) {
context.report({
node: node.value,
messageId: 'unexpectedInAttr',
data: {
attr: directive
}
})
}
}
}
})
}
}

View File

@ -0,0 +1,130 @@
/**
* @fileoverview Prevents boolean defaults from being set
* @author Hiroki Osame
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {import('../utils').ComponentProp} ComponentProp
*/
/**
* @param {Property | SpreadElement} prop
*/
function isBooleanProp(prop) {
return (
prop.type === 'Property' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'type' &&
prop.value.type === 'Identifier' &&
prop.value.name === 'Boolean'
)
}
/**
* @param {ObjectExpression} propDefValue
*/
function getDefaultNode(propDefValue) {
return utils.findProperty(propDefValue, 'default')
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow boolean defaults',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-boolean-default.html'
},
fixable: null,
schema: [
{
enum: ['default-false', 'no-default']
}
],
messages: {
noBooleanDefault:
'Boolean prop should not set a default (Vue defaults it to false).',
defaultFalse: 'Boolean prop should only be defaulted to false.'
}
},
/** @param {RuleContext} context */
create(context) {
const booleanType = context.options[0] || 'no-default'
/**
* @param {ComponentProp} prop
* @param { { [key: string]: Expression | undefined } } [withDefaultsExpressions]
*/
function processProp(prop, withDefaultsExpressions) {
if (prop.type === 'object') {
if (prop.value.type !== 'ObjectExpression') {
return
}
if (!prop.value.properties.some(isBooleanProp)) {
return
}
const defaultNode = getDefaultNode(prop.value)
if (!defaultNode) {
return
}
verifyDefaultExpression(defaultNode.value)
} else if (prop.type === 'type') {
if (prop.types.length !== 1 || prop.types[0] !== 'Boolean') {
return
}
const defaultNode =
withDefaultsExpressions && withDefaultsExpressions[prop.propName]
if (!defaultNode) {
return
}
verifyDefaultExpression(defaultNode)
}
}
/**
* @param {ComponentProp[]} props
* @param { { [key: string]: Expression | undefined } } [withDefaultsExpressions]
*/
function processProps(props, withDefaultsExpressions) {
for (const prop of props) {
processProp(prop, withDefaultsExpressions)
}
}
/**
* @param {Expression} defaultNode
*/
function verifyDefaultExpression(defaultNode) {
switch (booleanType) {
case 'no-default': {
context.report({
node: defaultNode,
messageId: 'noBooleanDefault'
})
break
}
case 'default-false': {
if (defaultNode.type !== 'Literal' || defaultNode.value !== false) {
context.report({
node: defaultNode,
messageId: 'defaultFalse'
})
}
break
}
}
}
return utils.compositingVisitors(
utils.executeOnVueComponent(context, (obj) => {
processProps(utils.getComponentPropsFromOptions(obj))
}),
utils.defineScriptSetupVisitor(context, {
onDefinePropsEnter(node, props) {
processProps(props, utils.getWithDefaultsPropExpressions(node))
}
})
)
}
}

View File

@ -0,0 +1,163 @@
/**
* @author Flo Edelmann
* See LICENSE file in root directory for full license.
*/
'use strict'
const { defineTemplateBodyVisitor } = require('../utils')
/**
* @typedef {object} RuleOption
* @property {string[]} additionalDirectives
*/
/**
* @param {VNode | Token} node
* @returns {boolean}
*/
function isWhiteSpaceTextNode(node) {
return node.type === 'VText' && node.value.trim() === ''
}
/**
* @param {Position} pos1
* @param {Position} pos2
* @returns {'less' | 'equal' | 'greater'}
*/
function comparePositions(pos1, pos2) {
if (
pos1.line < pos2.line ||
(pos1.line === pos2.line && pos1.column < pos2.column)
) {
return 'less'
}
if (
pos1.line > pos2.line ||
(pos1.line === pos2.line && pos1.column > pos2.column)
) {
return 'greater'
}
return 'equal'
}
/**
* @param {(VNode | Token)[]} nodes
* @returns {SourceLocation | undefined}
*/
function getLocationRange(nodes) {
/** @type {Position | undefined} */
let start
/** @type {Position | undefined} */
let end
for (const node of nodes) {
if (!start || comparePositions(node.loc.start, start) === 'less') {
start = node.loc.start
}
if (!end || comparePositions(node.loc.end, end) === 'greater') {
end = node.loc.end
}
}
if (start === undefined || end === undefined) {
return undefined
}
return { start, end }
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
"disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`",
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-child-content.html'
},
fixable: null,
hasSuggestions: true,
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
additionalDirectives: {
type: 'array',
uniqueItems: true,
minItems: 1,
items: {
type: 'string'
}
}
},
required: ['additionalDirectives']
}
],
messages: {
disallowedChildContent:
'Child content is disallowed because it will be overwritten by the v-{{ directiveName }} directive.',
removeChildContent: 'Remove child content.'
}
},
/** @param {RuleContext} context */
create(context) {
const directives = new Set(['html', 'text'])
/** @type {RuleOption | undefined} */
const option = context.options[0]
if (option !== undefined) {
for (const directive of option.additionalDirectives) {
directives.add(directive)
}
}
return defineTemplateBodyVisitor(context, {
/** @param {VDirective} directiveNode */
'VAttribute[directive=true]'(directiveNode) {
const directiveName = directiveNode.key.name.name
const elementNode = directiveNode.parent.parent
if (elementNode.endTag === null) {
return
}
const sourceCode = context.getSourceCode()
const tokenStore = sourceCode.parserServices.getTemplateBodyTokenStore()
const elementComments = tokenStore.getTokensBetween(
elementNode.startTag,
elementNode.endTag,
{
includeComments: true,
filter: (token) => token.type === 'HTMLComment'
}
)
const childNodes = [...elementNode.children, ...elementComments]
if (
directives.has(directiveName) &&
childNodes.some((childNode) => !isWhiteSpaceTextNode(childNode))
) {
context.report({
node: elementNode,
loc: getLocationRange(childNodes),
messageId: 'disallowedChildContent',
data: { directiveName },
suggest: [
{
messageId: 'removeChildContent',
*fix(fixer) {
for (const childNode of childNodes) {
yield fixer.remove(childNode)
}
}
}
]
})
}
}
})
}
}

View File

@ -0,0 +1,100 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {import('../utils').VueObjectData} VueObjectData
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow accessing computed properties in `data`.',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-computed-properties-in-data.html'
},
fixable: null,
schema: [],
messages: {
cannotBeUsed:
'The computed property cannot be used in `data()` because it is before initialization.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @type {Map<ObjectExpression, {data: FunctionExpression | ArrowFunctionExpression, computedNames:Set<string>}>} */
const contextMap = new Map()
/**
* @typedef {object} ScopeStack
* @property {ScopeStack | null} upper
* @property {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
*/
/** @type {ScopeStack | null} */
let scopeStack = null
return utils.compositingVisitors(
{
/**
* @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
*/
':function'(node) {
scopeStack = {
upper: scopeStack,
node
}
},
':function:exit'() {
scopeStack = scopeStack && scopeStack.upper
}
},
utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
const dataProperty = utils.findProperty(node, 'data')
if (
!dataProperty ||
(dataProperty.value.type !== 'FunctionExpression' &&
dataProperty.value.type !== 'ArrowFunctionExpression')
) {
return
}
const computedNames = new Set()
for (const computed of utils.iterateProperties(
node,
new Set(['computed'])
)) {
computedNames.add(computed.name)
}
contextMap.set(node, { data: dataProperty.value, computedNames })
},
/**
* @param {MemberExpression} node
* @param {VueObjectData} vueData
*/
MemberExpression(node, vueData) {
if (!scopeStack || !utils.isThis(node.object, context)) {
return
}
const ctx = contextMap.get(vueData.node)
if (!ctx || ctx.data !== scopeStack.node) {
return
}
const name = utils.getStaticPropertyName(node)
if (!name || !ctx.computedNames.has(name)) {
return
}
context.report({
node,
messageId: 'cannotBeUsed'
})
}
})
)
}
}

View File

@ -0,0 +1,45 @@
/**
* @author ItMaga <https://github.com/ItMaga>
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = utils.wrapCoreRule('no-console', {
skipBaseHandlers: true,
create(context) {
const options = context.options[0] || {}
const allowed = options.allow || []
/**
* Copied from the core rule `no-console`.
* Checks whether the property name of the given MemberExpression node
* is allowed by options or not.
* @param {MemberExpression} node The MemberExpression node to check.
* @returns {boolean} `true` if the property name of the node is allowed.
*/
function isAllowed(node) {
const propertyName = utils.getStaticPropertyName(node)
return propertyName && allowed.includes(propertyName)
}
return {
MemberExpression(node) {
if (
node.object.type === 'Identifier' &&
node.object.name === 'console' &&
!isAllowed(node)
) {
context.report({
node: node.object,
loc: node.object.loc,
messageId: 'unexpected'
})
}
}
}
}
})

View File

@ -0,0 +1,29 @@
/**
* @author Flo Edelmann
*/
'use strict'
const { wrapCoreRule } = require('../utils')
const conditionalDirectiveNames = new Set(['v-show', 'v-if', 'v-else-if'])
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapCoreRule('no-constant-condition', {
create(_context, { baseHandlers }) {
return {
VDirectiveKey(node) {
if (
conditionalDirectiveNames.has(`v-${node.name.name}`) &&
node.parent.value &&
node.parent.value.expression &&
baseHandlers.IfStatement
) {
baseHandlers.IfStatement({
// @ts-expect-error -- Process expression of VExpressionContainer as IfStatement.
test: node.parent.value.expression
})
}
}
}
}
})

View File

@ -0,0 +1,47 @@
/**
* @author Przemyslaw Falowski (@przemkow)
* @fileoverview This rule checks whether v-model used on the component do not have custom modifiers
*/
'use strict'
const utils = require('../utils')
const VALID_MODIFIERS = new Set(['lazy', 'number', 'trim'])
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow custom modifiers on v-model used on the component',
categories: ['essential'],
url: 'https://eslint.vuejs.org/rules/no-custom-modifiers-on-v-model.html'
},
fixable: null,
schema: [],
messages: {
notSupportedModifier:
"'v-model' directives don't support the modifier '{{name}}'."
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
"VAttribute[directive=true][key.name.name='model']"(node) {
const element = node.parent.parent
if (utils.isCustomComponent(element)) {
for (const modifier of node.key.modifiers) {
if (!VALID_MODIFIERS.has(modifier.name)) {
context.report({
node,
loc: node.loc,
messageId: 'notSupportedModifier',
data: { name: modifier.name }
})
}
}
}
}
})
}
}

View File

@ -0,0 +1,86 @@
/**
* @fileoverview disallow using deprecated object declaration on data
* @author yoyo930021
*/
'use strict'
const utils = require('../utils')
/** @param {Token} token */
function isOpenParen(token) {
return token.type === 'Punctuator' && token.value === '('
}
/** @param {Token} token */
function isCloseParen(token) {
return token.type === 'Punctuator' && token.value === ')'
}
/**
* @param {Expression} node
* @param {SourceCode} sourceCode
*/
function getFirstAndLastTokens(node, sourceCode) {
let first = sourceCode.getFirstToken(node)
let last = sourceCode.getLastToken(node)
// If the value enclosed by parentheses, update the 'first' and 'last' by the parentheses.
while (true) {
const prev = sourceCode.getTokenBefore(first)
const next = sourceCode.getTokenAfter(last)
if (isOpenParen(prev) && isCloseParen(next)) {
first = prev
last = next
} else {
return { first, last }
}
}
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated object declaration on data (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-data-object-declaration.html'
},
fixable: 'code',
schema: [],
messages: {
objectDeclarationIsDeprecated:
"Object declaration on 'data' property is deprecated. Using function declaration instead."
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
return utils.executeOnVue(context, (obj) => {
const invalidData = utils.findProperty(
obj,
'data',
(p) =>
p.value.type !== 'FunctionExpression' &&
p.value.type !== 'ArrowFunctionExpression' &&
p.value.type !== 'Identifier'
)
if (invalidData) {
context.report({
node: invalidData,
messageId: 'objectDeclarationIsDeprecated',
fix(fixer) {
const tokens = getFirstAndLastTokens(invalidData.value, sourceCode)
return [
fixer.insertTextBefore(tokens.first, 'function() {\nreturn '),
fixer.insertTextAfter(tokens.last, ';\n}')
]
}
})
}
})
}
}

View File

@ -0,0 +1,78 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @param {RuleFixer} fixer
* @param {Property} property
* @param {string} newName
*/
function fix(fixer, property, newName) {
if (property.computed) {
if (
property.key.type === 'Literal' ||
property.key.type === 'TemplateLiteral'
) {
return fixer.replaceTextRange(
[property.key.range[0] + 1, property.key.range[1] - 1],
newName
)
}
return null
}
if (property.shorthand) {
return fixer.insertTextBefore(property.key, `${newName}:`)
}
return fixer.replaceText(property.key, newName)
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated `destroyed` and `beforeDestroy` lifecycle hooks (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-destroyed-lifecycle.html'
},
fixable: 'code',
schema: [],
messages: {
deprecatedDestroyed:
'The `destroyed` lifecycle hook is deprecated. Use `unmounted` instead.',
deprecatedBeforeDestroy:
'The `beforeDestroy` lifecycle hook is deprecated. Use `beforeUnmount` instead.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.executeOnVue(context, (obj) => {
const destroyed = utils.findProperty(obj, 'destroyed')
if (destroyed) {
context.report({
node: destroyed.key,
messageId: 'deprecatedDestroyed',
fix(fixer) {
return fix(fixer, destroyed, 'unmounted')
}
})
}
const beforeDestroy = utils.findProperty(obj, 'beforeDestroy')
if (beforeDestroy) {
context.report({
node: beforeDestroy.key,
messageId: 'deprecatedBeforeDestroy',
fix(fixer) {
return fix(fixer, beforeDestroy, 'beforeUnmount')
}
})
}
})
}
}

View File

@ -0,0 +1,63 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow using deprecated `$listeners` (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-dollar-listeners-api.html'
},
fixable: null,
schema: [],
messages: {
deprecated: 'The `$listeners` is deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(
context,
{
VExpressionContainer(node) {
for (const reference of node.references) {
if (reference.variable != null) {
// Not vm reference
continue
}
if (reference.id.name === '$listeners') {
context.report({
node: reference.id,
messageId: 'deprecated'
})
}
}
}
},
utils.defineVueVisitor(context, {
MemberExpression(node) {
if (
node.property.type !== 'Identifier' ||
node.property.name !== '$listeners'
) {
return
}
if (!utils.isThis(node.object, context)) {
return
}
context.report({
node: node.property,
messageId: 'deprecated'
})
}
})
)
}
}

View File

@ -0,0 +1,70 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated `$scopedSlots` (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-dollar-scopedslots-api.html'
},
fixable: 'code',
schema: [],
messages: {
deprecated: 'The `$scopedSlots` is deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(
context,
{
VExpressionContainer(node) {
for (const reference of node.references) {
if (reference.variable != null) {
// Not vm reference
continue
}
if (reference.id.name === '$scopedSlots') {
context.report({
node: reference.id,
messageId: 'deprecated',
fix(fixer) {
return fixer.replaceText(reference.id, '$slots')
}
})
}
}
}
},
utils.defineVueVisitor(context, {
MemberExpression(node) {
if (
node.property.type !== 'Identifier' ||
node.property.name !== '$scopedSlots'
) {
return
}
if (!utils.isThis(node.object, context)) {
return
}
context.report({
node: node.property,
messageId: 'deprecated',
fix(fixer) {
return fixer.replaceText(node.property, '$slots')
}
})
}
})
)
}
}

View File

@ -0,0 +1,61 @@
/**
* @fileoverview disallow using deprecated events api
* @author yoyo930021
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow using deprecated events api (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-events-api.html'
},
fixable: null,
schema: [],
messages: {
noDeprecatedEventsApi:
'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineVueVisitor(context, {
/** @param {MemberExpression & ({parent: CallExpression} | {parent: ChainExpression & {parent: CallExpression}})} node */
'CallExpression > MemberExpression, CallExpression > ChainExpression > MemberExpression'(
node
) {
const call =
node.parent.type === 'ChainExpression'
? node.parent.parent
: node.parent
if (call.optional) {
// It is OK because checking whether it is deprecated.
// e.g. `this.$on?.()`
return
}
if (
utils.skipChainExpression(call.callee) !== node ||
!['$on', '$off', '$once'].includes(
utils.getStaticPropertyName(node) || ''
)
) {
return
}
if (!utils.isThis(node.object, context)) {
return
}
context.report({
node: node.property,
messageId: 'noDeprecatedEventsApi'
})
}
})
}
}

View File

@ -0,0 +1,36 @@
/**
* @author Przemyslaw Falowski (@przemkow)
* @fileoverview disallow using deprecated filters syntax
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated filters syntax (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-filter.html'
},
fixable: null,
schema: [],
messages: {
noDeprecatedFilter: 'Filters are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
VFilterSequenceExpression(node) {
context.report({
node,
loc: node.loc,
messageId: 'noDeprecatedFilter'
})
}
})
}
}

View File

@ -0,0 +1,47 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated the `functional` template (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-functional-template.html'
},
fixable: null,
schema: [],
messages: {
unexpected: 'The `functional` template are deprecated.'
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
return {
Program(program) {
const element = program.templateBody
if (element == null) {
return
}
const functional = utils.getAttribute(element, 'functional')
if (functional) {
context.report({
node: functional,
messageId: 'unexpected'
})
}
}
}
}
}

View File

@ -0,0 +1,64 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated the `is` attribute on HTML elements (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-html-element-is.html'
},
fixable: null,
schema: [],
messages: {
unexpected: 'The `is` attribute on HTML element are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @param {VElement} node */
function isValidElement(node) {
return (
!utils.isHtmlWellKnownElementName(node.rawName) &&
!utils.isSvgWellKnownElementName(node.rawName)
)
}
return utils.defineTemplateBodyVisitor(context, {
/** @param {VDirective} node */
"VAttribute[directive=true][key.name.name='bind'][key.argument.name='is']"(
node
) {
if (isValidElement(node.parent.parent)) {
return
}
context.report({
node,
loc: node.loc,
messageId: 'unexpected'
})
},
/** @param {VAttribute} node */
"VAttribute[directive=false][key.name='is']"(node) {
if (isValidElement(node.parent.parent)) {
return
}
if (node.value && node.value.value.startsWith('vue:')) {
// Usage on native elements 3.1+
return
}
context.report({
node,
loc: node.loc,
messageId: 'unexpected'
})
}
})
}
}

View File

@ -0,0 +1,39 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated `inline-template` attribute (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-inline-template.html'
},
fixable: null,
schema: [],
messages: {
unexpected: '`inline-template` are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
/** @param {VIdentifier} node */
"VAttribute[directive=false] > VIdentifier[rawName='inline-template']"(
node
) {
context.report({
node,
loc: node.loc,
messageId: 'unexpected'
})
}
})
}
}

View File

@ -0,0 +1,133 @@
/**
* @author Flo Edelmann
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const allowedPropNames = new Set(['modelValue', 'model-value'])
const allowedEventNames = new Set(['update:modelValue', 'update:model-value'])
/**
* @param {ObjectExpression} node
* @param {string} key
* @returns {Literal | TemplateLiteral | undefined}
*/
function findPropertyValue(node, key) {
const property = node.properties.find(
(property) =>
property.type === 'Property' &&
property.key.type === 'Identifier' &&
property.key.name === key
)
if (
!property ||
property.type !== 'Property' ||
!utils.isStringLiteral(property.value)
) {
return undefined
}
return property.value
}
/**
* @param {RuleFixer} fixer
* @param {Literal | TemplateLiteral} node
* @param {string} text
*/
function replaceLiteral(fixer, node, text) {
return fixer.replaceTextRange([node.range[0] + 1, node.range[1] - 1], text)
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow deprecated `model` definition (in Vue.js 3.0.0+)',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-deprecated-model-definition.html'
},
fixable: null,
hasSuggestions: true,
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
allowVue3Compat: {
type: 'boolean'
}
}
}
],
messages: {
deprecatedModel: '`model` definition is deprecated.',
vue3Compat:
'`model` definition is deprecated. You may use the Vue 3-compatible `modelValue`/`update:modelValue` though.',
changeToModelValue: 'Change to `modelValue`/`update:modelValue`.',
changeToKebabModelValue: 'Change to `model-value`/`update:model-value`.'
}
},
/** @param {RuleContext} context */
create(context) {
const allowVue3Compat = Boolean(context.options[0]?.allowVue3Compat)
return utils.executeOnVue(context, (obj) => {
const modelProperty = utils.findProperty(obj, 'model')
if (!modelProperty || modelProperty.value.type !== 'ObjectExpression') {
return
}
if (!allowVue3Compat) {
context.report({
node: modelProperty,
messageId: 'deprecatedModel'
})
return
}
const propName = findPropertyValue(modelProperty.value, 'prop')
const eventName = findPropertyValue(modelProperty.value, 'event')
if (
!propName ||
!eventName ||
!allowedPropNames.has(
utils.getStringLiteralValue(propName, true) ?? ''
) ||
!allowedEventNames.has(
utils.getStringLiteralValue(eventName, true) ?? ''
)
) {
context.report({
node: modelProperty,
messageId: 'vue3Compat',
suggest:
propName && eventName
? [
{
messageId: 'changeToModelValue',
*fix(fixer) {
const newPropName = 'modelValue'
const newEventName = 'update:modelValue'
yield replaceLiteral(fixer, propName, newPropName)
yield replaceLiteral(fixer, eventName, newEventName)
}
},
{
messageId: 'changeToKebabModelValue',
*fix(fixer) {
const newPropName = 'model-value'
const newEventName = 'update:model-value'
yield replaceLiteral(fixer, propName, newPropName)
yield replaceLiteral(fixer, eventName, newEventName)
}
}
]
: []
})
}
})
}
}

View File

@ -0,0 +1,126 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @param {Expression|SpreadElement|null} node
*/
function isFunctionIdentifier(node) {
return node && node.type === 'Identifier' && node.name === 'Function'
}
/**
* @param {Expression} node
* @returns {boolean}
*/
function hasFunctionType(node) {
if (isFunctionIdentifier(node)) {
return true
}
if (node.type === 'ArrayExpression') {
return node.elements.some(isFunctionIdentifier)
}
return false
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow deprecated `this` access in props default function (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-props-default-this.html'
},
fixable: null,
schema: [],
messages: {
deprecated:
'Props default value factory functions no longer have access to `this`.'
}
},
/** @param {RuleContext} context */
create(context) {
/**
* @typedef {object} ScopeStack
* @property {ScopeStack | null} upper
* @property {FunctionExpression | FunctionDeclaration} node
* @property {boolean} propDefault
*/
/** @type {Set<FunctionExpression>} */
const propsDefault = new Set()
/** @type {ScopeStack | null} */
let scopeStack = null
/**
* @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
*/
function onFunctionEnter(node) {
if (node.type === 'ArrowFunctionExpression') {
return
}
if (scopeStack) {
scopeStack = {
upper: scopeStack,
node,
propDefault: false
}
} else if (node.type === 'FunctionExpression' && propsDefault.has(node)) {
scopeStack = {
upper: scopeStack,
node,
propDefault: true
}
}
}
/**
* @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
*/
function onFunctionExit(node) {
if (scopeStack && scopeStack.node === node) {
scopeStack = scopeStack.upper
}
}
return utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
for (const prop of utils.getComponentPropsFromOptions(node)) {
if (prop.type !== 'object') {
continue
}
if (prop.value.type !== 'ObjectExpression') {
continue
}
const def = utils.findProperty(prop.value, 'default')
if (!def) {
continue
}
const type = utils.findProperty(prop.value, 'type')
if (type && hasFunctionType(type.value)) {
// ignore function type
continue
}
if (def.value.type !== 'FunctionExpression') {
continue
}
propsDefault.add(def.value)
}
},
':function': onFunctionEnter,
':function:exit': onFunctionExit,
ThisExpression(node) {
if (scopeStack && scopeStack.propDefault) {
context.report({
node,
messageId: 'deprecated'
})
}
}
})
}
}

View File

@ -0,0 +1,93 @@
/**
* @author Marton Csordas
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const casing = require('../utils/casing')
/** @param {RuleContext} context */
function getComponentNames(context) {
let components = ['RouterLink']
if (context.options[0] && context.options[0].components) {
components = context.options[0].components
}
return new Set(
components.flatMap((component) => [
casing.kebabCase(component),
casing.pascalCase(component)
])
)
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated `tag` property on `RouterLink` (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-router-link-tag-prop.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
components: {
type: 'array',
items: {
type: 'string'
},
uniqueItems: true,
minItems: 1
}
},
additionalProperties: false
}
],
messages: {
deprecated:
"'tag' property on '{{element}}' component is deprecated. Use scoped slots instead."
}
},
/** @param {RuleContext} context */
create(context) {
const components = getComponentNames(context)
return utils.defineTemplateBodyVisitor(context, {
VElement(node) {
if (!components.has(node.rawName)) return
/** @type VIdentifier | null */
let tagKey = null
const tagAttr = utils.getAttribute(node, 'tag')
if (tagAttr) {
tagKey = tagAttr.key
} else {
const directive = utils.getDirective(node, 'bind', 'tag')
if (directive) {
const arg = directive.key.argument
if (arg && arg.type === 'VIdentifier') {
tagKey = arg
}
}
}
if (tagKey) {
context.report({
node: tagKey,
messageId: 'deprecated',
data: {
element: node.rawName
}
})
}
}
})
}
}

View File

@ -0,0 +1,31 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const scopeAttribute = require('./syntaxes/scope-attribute')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow deprecated `scope` attribute (in Vue.js 2.5.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-scope-attribute.html'
},
// eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized
fixable: 'code',
schema: [],
messages: {
forbiddenScopeAttribute: '`scope` attributes are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
const templateBodyVisitor =
scopeAttribute.createTemplateBodyVisitor(context)
return utils.defineTemplateBodyVisitor(context, templateBodyVisitor)
}
}

View File

@ -0,0 +1,42 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const slotAttribute = require('./syntaxes/slot-attribute')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow deprecated `slot` attribute (in Vue.js 2.6.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-slot-attribute.html'
},
// eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized
fixable: 'code',
schema: [
{
type: 'object',
properties: {
ignore: {
type: 'array',
items: { type: 'string' },
uniqueItems: true
}
},
additionalProperties: false
}
],
messages: {
forbiddenSlotAttribute: '`slot` attributes are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
const templateBodyVisitor = slotAttribute.createTemplateBodyVisitor(context)
return utils.defineTemplateBodyVisitor(context, templateBodyVisitor)
}
}

View File

@ -0,0 +1,34 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const slotScopeAttribute = require('./syntaxes/slot-scope-attribute')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'disallow deprecated `slot-scope` attribute (in Vue.js 2.6.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-slot-scope-attribute.html'
},
// eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized
fixable: 'code',
schema: [],
messages: {
forbiddenSlotScopeAttribute: '`slot-scope` are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
const templateBodyVisitor = slotScopeAttribute.createTemplateBodyVisitor(
context,
{ fixToUpgrade: true }
)
return utils.defineTemplateBodyVisitor(context, templateBodyVisitor)
}
}

View File

@ -0,0 +1,54 @@
/**
* @author Przemyslaw Falowski (@przemkow)
* @fileoverview Disallow use of deprecated `.sync` modifier on `v-bind` directive (in Vue.js 3.0.0+)
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow use of deprecated `.sync` modifier on `v-bind` directive (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-v-bind-sync.html'
},
fixable: 'code',
schema: [],
messages: {
syncModifierIsDeprecated:
"'.sync' modifier on 'v-bind' directive is deprecated. Use 'v-model:propName' instead."
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
"VAttribute[directive=true][key.name.name='bind']"(node) {
if (node.key.modifiers.map((mod) => mod.name).includes('sync')) {
context.report({
node,
loc: node.loc,
messageId: 'syncModifierIsDeprecated',
fix(fixer) {
if (node.key.argument == null) {
// is using spread syntax
return null
}
if (node.key.modifiers.length > 1) {
// has multiple modifiers
return null
}
const bindArgument = context
.getSourceCode()
.getText(node.key.argument)
return fixer.replaceText(node.key, `v-model:${bindArgument}`)
}
})
}
}
})
}
}

View File

@ -0,0 +1,29 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
const vIs = require('./syntaxes/v-is')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow deprecated `v-is` directive (in Vue.js 3.1.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-v-is.html'
},
fixable: null,
schema: [],
messages: {
forbiddenVIs: '`v-is` directive is deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
const templateBodyVisitor = vIs.createTemplateBodyVisitor(context)
return utils.defineTemplateBodyVisitor(context, templateBodyVisitor)
}
}

View File

@ -0,0 +1,41 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated `.native` modifiers (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-v-on-native-modifier.html'
},
fixable: null,
schema: [],
messages: {
deprecated: "'.native' modifier on 'v-on' directive is deprecated."
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
/** @param {VIdentifier & {parent:VDirectiveKey} } node */
"VAttribute[directive=true][key.name.name='on'] > VDirectiveKey > VIdentifier[name='native']"(
node
) {
const key = node.parent
if (!key.modifiers.includes(node)) return
context.report({
node,
messageId: 'deprecated'
})
}
})
}
}

View File

@ -0,0 +1,52 @@
/**
* @fileoverview disallow using deprecated number (keycode) modifiers
* @author yoyo930021
*/
'use strict'
const utils = require('../utils')
const keyCodeToKey = require('../utils/keycode-to-key')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated number (keycode) modifiers (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-v-on-number-modifiers.html'
},
fixable: 'code',
schema: [],
messages: {
numberModifierIsDeprecated:
"'KeyboardEvent.keyCode' modifier on 'v-on' directive is deprecated. Using 'KeyboardEvent.key' instead."
}
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
/** @param {VDirectiveKey} node */
"VAttribute[directive=true][key.name.name='on'] > VDirectiveKey"(node) {
const modifier = node.modifiers.find((mod) =>
Number.isInteger(Number.parseInt(mod.name, 10))
)
if (!modifier) return
const keyCodes = Number.parseInt(modifier.name, 10)
if (keyCodes > 9 || keyCodes < 0) {
context.report({
node: modifier,
messageId: 'numberModifierIsDeprecated',
fix(fixer) {
const key = keyCodeToKey[keyCodes]
if (!key) return null
return fixer.replaceText(modifier, `${key}`)
}
})
}
}
})
}
}

View File

@ -0,0 +1,48 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow using deprecated `Vue.config.keyCodes` (in Vue.js 3.0.0+)',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-deprecated-vue-config-keycodes.html'
},
fixable: null,
schema: [],
messages: {
unexpected: '`Vue.config.keyCodes` are deprecated.'
}
},
/** @param {RuleContext} context */
create(context) {
return {
/** @param {MemberExpression} node */
"MemberExpression[property.type='Identifier'][property.name='keyCodes']"(
node
) {
const config = utils.skipChainExpression(node.object)
if (
config.type !== 'MemberExpression' ||
config.property.type !== 'Identifier' ||
config.property.name !== 'config' ||
config.object.type !== 'Identifier' ||
config.object.name !== 'Vue'
) {
return
}
context.report({
node,
messageId: 'unexpected'
})
}
}
}
}

View File

@ -0,0 +1,175 @@
/**
* @fileoverview Prevents duplication of field names.
* @author Armano
*/
'use strict'
const { findVariable } = require('@eslint-community/eslint-utils')
const utils = require('../utils')
/**
* @typedef {import('../utils').GroupName} GroupName
* @typedef {import('eslint').Scope.Variable} Variable
* @typedef {import('../utils').ComponentProp} ComponentProp
*/
/** @type {GroupName[]} */
const GROUP_NAMES = ['props', 'computed', 'data', 'methods', 'setup']
/**
* Gets the props pattern node from given `defineProps()` node
* @param {CallExpression} node
* @returns {Pattern|null}
*/
function getPropsPattern(node) {
let target = node
if (
target.parent &&
target.parent.type === 'CallExpression' &&
target.parent.arguments[0] === target &&
target.parent.callee.type === 'Identifier' &&
target.parent.callee.name === 'withDefaults'
) {
target = target.parent
}
if (
!target.parent ||
target.parent.type !== 'VariableDeclarator' ||
target.parent.init !== target
) {
return null
}
return target.parent.id
}
/**
* Checks whether the initialization of the given variable declarator node contains one of the references.
* @param {VariableDeclarator} node
* @param {ESNode[]} references
*/
function isInsideInitializer(node, references) {
const init = node.init
if (!init) {
return false
}
return references.some(
(id) => init.range[0] <= id.range[0] && id.range[1] <= init.range[1]
)
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow duplication of field names',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-dupe-keys.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
groups: {
type: 'array'
}
},
additionalProperties: false
}
],
messages: {
duplicateKey:
"Duplicate key '{{name}}'. May cause name collision in script or template tag."
}
},
/** @param {RuleContext} context */
create(context) {
const options = context.options[0] || {}
const groups = new Set([...GROUP_NAMES, ...(options.groups || [])])
return utils.compositingVisitors(
utils.executeOnVue(context, (obj) => {
const properties = utils.iterateProperties(obj, groups)
/** @type {Set<string>} */
const usedNames = new Set()
for (const o of properties) {
if (usedNames.has(o.name)) {
context.report({
node: o.node,
messageId: 'duplicateKey',
data: {
name: o.name
}
})
}
usedNames.add(o.name)
}
}),
utils.defineScriptSetupVisitor(context, {
onDefinePropsEnter(node, props) {
const propsNode = getPropsPattern(node)
const propReferences = [
...(propsNode ? extractReferences(propsNode) : []),
node
]
for (const prop of props) {
if (!prop.propName) continue
const variable = findVariable(
utils.getScope(context, node),
prop.propName
)
if (!variable || variable.defs.length === 0) continue
if (
variable.defs.some((def) => {
if (def.type !== 'Variable') return false
return isInsideInitializer(def.node, propReferences)
})
) {
continue
}
context.report({
node: variable.defs[0].node,
messageId: 'duplicateKey',
data: {
name: prop.propName
}
})
}
}
})
)
/**
* Extracts references from the given node.
* @param {Pattern} node
* @returns {Identifier[]} References
*/
function extractReferences(node) {
if (node.type === 'Identifier') {
const variable = findVariable(utils.getScope(context, node), node)
if (!variable) {
return []
}
return variable.references.map((ref) => ref.identifier)
}
if (node.type === 'ObjectPattern') {
return node.properties.flatMap((prop) =>
extractReferences(prop.type === 'Property' ? prop.value : prop)
)
}
if (node.type === 'AssignmentPattern') {
return extractReferences(node.left)
}
if (node.type === 'RestElement') {
return extractReferences(node.argument)
}
return []
}
}
}

View File

@ -0,0 +1,181 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {NonNullable<VExpressionContainer['expression']>} VExpression
*/
/**
* @typedef {object} OrOperands
* @property {VExpression} OrOperands.node
* @property {AndOperands[]} OrOperands.operands
*
* @typedef {object} AndOperands
* @property {VExpression} AndOperands.node
* @property {VExpression[]} AndOperands.operands
*/
/**
* Splits the given node by the given logical operator.
* @param {string} operator Logical operator `||` or `&&`.
* @param {VExpression} node The node to split.
* @returns {VExpression[]} Array of conditions that makes the node when joined by the operator.
*/
function splitByLogicalOperator(operator, node) {
if (node.type === 'LogicalExpression' && node.operator === operator) {
return [
...splitByLogicalOperator(operator, node.left),
...splitByLogicalOperator(operator, node.right)
]
}
return [node]
}
/**
* @param {VExpression} node
*/
function splitByOr(node) {
return splitByLogicalOperator('||', node)
}
/**
* @param {VExpression} node
*/
function splitByAnd(node) {
return splitByLogicalOperator('&&', node)
}
/**
* @param {VExpression} node
* @returns {OrOperands}
*/
function buildOrOperands(node) {
const orOperands = splitByOr(node)
return {
node,
operands: orOperands.map((orOperand) => {
const andOperands = splitByAnd(orOperand)
return {
node: orOperand,
operands: andOperands
}
})
}
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'disallow duplicate conditions in `v-if` / `v-else-if` chains',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-dupe-v-else-if.html'
},
fixable: null,
schema: [],
messages: {
unexpected:
'This branch can never execute. Its condition is a duplicate or covered by previous conditions in the `v-if` / `v-else-if` chain.'
}
},
/** @param {RuleContext} context */
create(context) {
const sourceCode = context.getSourceCode()
const tokenStore =
sourceCode.parserServices.getTemplateBodyTokenStore &&
sourceCode.parserServices.getTemplateBodyTokenStore()
/**
* Determines whether the two given nodes are considered to be equal. In particular, given that the nodes
* represent expressions in a boolean context, `||` and `&&` can be considered as commutative operators.
* @param {VExpression} a First node.
* @param {VExpression} b Second node.
* @returns {boolean} `true` if the nodes are considered to be equal.
*/
function equal(a, b) {
if (a.type !== b.type) {
return false
}
if (
a.type === 'LogicalExpression' &&
b.type === 'LogicalExpression' &&
(a.operator === '||' || a.operator === '&&') &&
a.operator === b.operator
) {
return (
(equal(a.left, b.left) && equal(a.right, b.right)) ||
(equal(a.left, b.right) && equal(a.right, b.left))
)
}
return utils.equalTokens(a, b, tokenStore)
}
/**
* Determines whether the first given AndOperands is a subset of the second given AndOperands.
*
* e.g. A: (a && b), B: (a && b && c): B is a subset of A.
*
* @param {AndOperands} operandsA The AndOperands to compare from.
* @param {AndOperands} operandsB The AndOperands to compare against.
* @returns {boolean} `true` if the `andOperandsA` is a subset of the `andOperandsB`.
*/
function isSubset(operandsA, operandsB) {
return operandsA.operands.every((operandA) =>
operandsB.operands.some((operandB) => equal(operandA, operandB))
)
}
return utils.defineTemplateBodyVisitor(context, {
"VAttribute[directive=true][key.name.name='else-if']"(node) {
if (!node.value || !node.value.expression) {
return
}
const test = node.value.expression
const conditionsToCheck =
test.type === 'LogicalExpression' && test.operator === '&&'
? [...splitByAnd(test), test]
: [test]
const listToCheck = conditionsToCheck.map(buildOrOperands)
/** @type {VElement | null} */
let current = node.parent.parent
while (current && (current = utils.prevSibling(current))) {
const vIf = utils.getDirective(current, 'if')
const currentTestDir = vIf || utils.getDirective(current, 'else-if')
if (!currentTestDir) {
return
}
if (currentTestDir.value && currentTestDir.value.expression) {
const currentOrOperands = buildOrOperands(
currentTestDir.value.expression
)
for (const condition of listToCheck) {
const operands = (condition.operands = condition.operands.filter(
(orOperand) =>
!currentOrOperands.operands.some((currentOrOperand) =>
isSubset(currentOrOperand, orOperand)
)
))
if (operands.length === 0) {
context.report({
node: condition.node,
messageId: 'unexpected'
})
return
}
}
}
if (vIf) {
return
}
}
}
})
}
}

View File

@ -0,0 +1,75 @@
/**
* @fileoverview Disable inheritAttrs when using v-bind="$attrs"
* @author Hiroki Osame
*/
'use strict'
const utils = require('../utils')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"`',
categories: undefined,
recommended: false,
url: 'https://eslint.vuejs.org/rules/no-duplicate-attr-inheritance.html'
},
fixable: null,
schema: [],
messages: {
noDuplicateAttrInheritance: 'Set "inheritAttrs" to false.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @type {string | number | boolean | RegExp | BigInt | null} */
let inheritsAttrs = true
/** @param {ObjectExpression} node */
function processOptions(node) {
const inheritAttrsProp = utils.findProperty(node, 'inheritAttrs')
if (inheritAttrsProp && inheritAttrsProp.value.type === 'Literal') {
inheritsAttrs = inheritAttrsProp.value.value
}
}
return utils.compositingVisitors(
utils.executeOnVue(context, processOptions),
utils.defineScriptSetupVisitor(context, {
onDefineOptionsEnter(node) {
if (node.arguments.length === 0) return
const define = node.arguments[0]
if (define.type !== 'ObjectExpression') return
processOptions(define)
}
}),
utils.defineTemplateBodyVisitor(context, {
/** @param {VExpressionContainer} node */
"VAttribute[directive=true][key.name.name='bind'][key.argument=null] > VExpressionContainer"(
node
) {
if (!inheritsAttrs) {
return
}
const attrsRef = node.references.find((reference) => {
if (reference.variable != null) {
// Not vm reference
return false
}
return reference.id.name === '$attrs'
})
if (attrsRef) {
context.report({
node: attrsRef.id,
messageId: 'noDuplicateAttrInheritance'
})
}
}
})
)
}
}

View File

@ -0,0 +1,111 @@
/**
* @author Toru Nagashima
* @copyright 2017 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* Get the name of the given attribute node.
* @param {VAttribute | VDirective} attribute The attribute node to get.
* @returns {string | null} The name of the attribute.
*/
function getName(attribute) {
if (!attribute.directive) {
return attribute.key.name
}
if (attribute.key.name.name === 'bind') {
return (
(attribute.key.argument &&
attribute.key.argument.type === 'VIdentifier' &&
attribute.key.argument.name) ||
null
)
}
return null
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow duplication of attributes',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-duplicate-attributes.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
allowCoexistClass: {
type: 'boolean'
},
allowCoexistStyle: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
duplicateAttribute: "Duplicate attribute '{{name}}'."
}
},
/** @param {RuleContext} context */
create(context) {
const options = context.options[0] || {}
const allowCoexistStyle = options.allowCoexistStyle !== false
const allowCoexistClass = options.allowCoexistClass !== false
/** @type {Set<string>} */
const directiveNames = new Set()
/** @type {Set<string>} */
const attributeNames = new Set()
/**
* @param {string} name
* @param {boolean} isDirective
*/
function isDuplicate(name, isDirective) {
if (
(allowCoexistStyle && name === 'style') ||
(allowCoexistClass && name === 'class')
) {
return isDirective ? directiveNames.has(name) : attributeNames.has(name)
}
return directiveNames.has(name) || attributeNames.has(name)
}
return utils.defineTemplateBodyVisitor(context, {
VStartTag() {
directiveNames.clear()
attributeNames.clear()
},
VAttribute(node) {
const name = getName(node)
if (name == null) {
return
}
if (isDuplicate(name, node.directive)) {
context.report({
node,
loc: node.loc,
messageId: 'duplicateAttribute',
data: { name }
})
}
if (node.directive) {
directiveNames.add(name)
} else {
attributeNames.add(name)
}
}
})
}
}

View File

@ -0,0 +1,101 @@
/**
* @author tyankatsu <https://github.com/tyankatsu0105>
* See LICENSE file in root directory for full license.
*/
'use strict'
const { isVElement } = require('../utils')
/**
* check whether has attribute `src`
* @param {VElement} componentBlock
*/
function hasAttributeSrc(componentBlock) {
const hasAttribute = componentBlock.startTag.attributes.length > 0
const hasSrc = componentBlock.startTag.attributes.some(
(attribute) =>
!attribute.directive &&
attribute.key.name === 'src' &&
attribute.value &&
attribute.value.value !== ''
)
return hasAttribute && hasSrc
}
/**
* check whether value under the component block is only whitespaces or break lines
* @param {VElement} componentBlock
*/
function isValueOnlyWhiteSpacesOrLineBreaks(componentBlock) {
return (
componentBlock.children.length === 1 &&
componentBlock.children[0].type === 'VText' &&
!componentBlock.children[0].value.trim()
)
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'disallow the `<template>` `<script>` `<style>` block to be empty',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-empty-component-block.html'
},
fixable: null,
schema: [],
messages: {
unexpected: '`<{{ blockName }}>` is empty. Empty block is not allowed.'
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
const sourceCode = context.getSourceCode()
if (!sourceCode.parserServices.getDocumentFragment) {
return {}
}
const documentFragment = sourceCode.parserServices.getDocumentFragment()
if (!documentFragment) {
return {}
}
const componentBlocks = documentFragment.children.filter(isVElement)
return {
Program() {
for (const componentBlock of componentBlocks) {
if (
componentBlock.name !== 'template' &&
componentBlock.name !== 'script' &&
componentBlock.name !== 'style'
)
continue
// https://vue-loader.vuejs.org/spec.html#src-imports
if (hasAttributeSrc(componentBlock)) continue
if (
isValueOnlyWhiteSpacesOrLineBreaks(componentBlock) ||
componentBlock.children.length === 0
) {
context.report({
node: componentBlock,
loc: componentBlock.loc,
messageId: 'unexpected',
data: {
blockName: componentBlock.name
}
})
}
}
}
}
}
}

View File

@ -0,0 +1,9 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { wrapCoreRule } = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapCoreRule('no-empty-pattern')

View File

@ -0,0 +1,59 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
/**
* @typedef {import('@typescript-eslint/types').TSESTree.ExportAllDeclaration} TSESTreeExportAllDeclaration
* @typedef {import('@typescript-eslint/types').TSESTree.ExportDefaultDeclaration} TSESTreeExportDefaultDeclaration
* @typedef {import('@typescript-eslint/types').TSESTree.ExportNamedDeclaration} TSESTreeExportNamedDeclaration
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow `export` in `<script setup>`',
categories: ['vue3-essential', 'essential'],
url: 'https://eslint.vuejs.org/rules/no-export-in-script-setup.html'
},
fixable: null,
schema: [],
messages: {
forbidden: '`<script setup>` cannot contain ES module exports.'
}
},
/** @param {RuleContext} context */
create(context) {
/** @param {ExportAllDeclaration | ExportDefaultDeclaration | ExportNamedDeclaration} node */
function verify(node) {
const tsNode =
/** @type {TSESTreeExportAllDeclaration | TSESTreeExportDefaultDeclaration | TSESTreeExportNamedDeclaration} */ (
node
)
if (tsNode.exportKind === 'type') {
return
}
if (
tsNode.type === 'ExportNamedDeclaration' &&
tsNode.specifiers.length > 0 &&
tsNode.specifiers.every((spec) => spec.exportKind === 'type')
) {
return
}
context.report({
node,
messageId: 'forbidden'
})
}
return utils.defineScriptSetupVisitor(context, {
ExportAllDeclaration: verify,
ExportDefaultDeclaration: verify,
ExportNamedDeclaration: verify
})
}
}

View File

@ -0,0 +1,238 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const { findVariable } = require('@eslint-community/eslint-utils')
const utils = require('../utils')
/**
* Get the callee member node from the given CallExpression
* @param {CallExpression} node CallExpression
*/
function getCalleeMemberNode(node) {
const callee = utils.skipChainExpression(node.callee)
if (callee.type === 'MemberExpression') {
const name = utils.getStaticPropertyName(callee)
if (name) {
return { name, member: callee }
}
}
return null
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow asynchronously registered `expose`',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-expose-after-await.html'
},
fixable: null,
schema: [],
messages: {
forbidden: '`{{name}}` is forbidden after an `await` expression.'
}
},
/** @param {RuleContext} context */
create(context) {
/**
* @typedef {object} SetupScopeData
* @property {boolean} afterAwait
* @property {[number,number]} range
* @property {(node: Identifier, callNode: CallExpression) => boolean} isExposeReferenceId
* @property {(node: Identifier) => boolean} isContextReferenceId
*/
/**
* @typedef {object} ScopeStack
* @property {ScopeStack | null} upper
* @property {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | Program} scopeNode
*/
/** @type {Map<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | Program, SetupScopeData>} */
const setupScopes = new Map()
/** @type {ScopeStack | null} */
let scopeStack = null
return utils.compositingVisitors(
{
/**
* @param {Program} node
*/
Program(node) {
scopeStack = {
upper: scopeStack,
scopeNode: node
}
}
},
{
/**
* @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
*/
':function'(node) {
scopeStack = {
upper: scopeStack,
scopeNode: node
}
},
':function:exit'() {
scopeStack = scopeStack && scopeStack.upper
},
/** @param {AwaitExpression} node */
AwaitExpression(node) {
if (!scopeStack) {
return
}
const setupScope = setupScopes.get(scopeStack.scopeNode)
if (!setupScope || !utils.inRange(setupScope.range, node)) {
return
}
setupScope.afterAwait = true
},
/** @param {CallExpression} node */
CallExpression(node) {
if (!scopeStack) {
return
}
const setupScope = setupScopes.get(scopeStack.scopeNode)
if (
!setupScope ||
!setupScope.afterAwait ||
!utils.inRange(setupScope.range, node)
) {
return
}
const { isContextReferenceId, isExposeReferenceId } = setupScope
if (
node.callee.type === 'Identifier' &&
isExposeReferenceId(node.callee, node)
) {
// setup(props,{expose}) {expose()}
context.report({
node,
messageId: 'forbidden',
data: {
name: node.callee.name
}
})
} else {
const expose = getCalleeMemberNode(node)
if (
expose &&
expose.name === 'expose' &&
expose.member.object.type === 'Identifier' &&
isContextReferenceId(expose.member.object)
) {
// setup(props,context) {context.emit()}
context.report({
node,
messageId: 'forbidden',
data: {
name: expose.name
}
})
}
}
}
},
(() => {
const scriptSetup = utils.getScriptSetupElement(context)
if (!scriptSetup) {
return {}
}
return {
/**
* @param {Program} node
*/
Program(node) {
setupScopes.set(node, {
afterAwait: false,
range: scriptSetup.range,
isExposeReferenceId: (id, callNode) =>
callNode.parent.type === 'ExpressionStatement' &&
callNode.parent.parent === node &&
id.name === 'defineExpose',
isContextReferenceId: () => false
})
}
}
})(),
utils.defineVueVisitor(context, {
onSetupFunctionEnter(node) {
const contextParam = node.params[1]
if (!contextParam) {
// no arguments
return
}
if (contextParam.type === 'RestElement') {
// cannot check
return
}
if (contextParam.type === 'ArrayPattern') {
// cannot check
return
}
/** @type {Set<Identifier>} */
const contextReferenceIds = new Set()
/** @type {Set<Identifier>} */
const exposeReferenceIds = new Set()
if (contextParam.type === 'ObjectPattern') {
const exposeProperty = utils.findAssignmentProperty(
contextParam,
'expose'
)
if (!exposeProperty) {
return
}
const exposeParam = exposeProperty.value
// `setup(props, {emit})`
const variable =
exposeParam.type === 'Identifier'
? findVariable(
utils.getScope(context, exposeParam),
exposeParam
)
: null
if (!variable) {
return
}
for (const reference of variable.references) {
if (!reference.isRead()) {
continue
}
exposeReferenceIds.add(reference.identifier)
}
} else if (contextParam.type === 'Identifier') {
// `setup(props, context)`
const variable = findVariable(
utils.getScope(context, contextParam),
contextParam
)
if (!variable) {
return
}
for (const reference of variable.references) {
if (!reference.isRead()) {
continue
}
contextReferenceIds.add(reference.identifier)
}
}
setupScopes.set(node, {
afterAwait: false,
range: node.range,
isExposeReferenceId: (id) => exposeReferenceIds.has(id),
isContextReferenceId: (id) => contextReferenceIds.has(id)
})
},
onSetupFunctionExit(node) {
setupScopes.delete(node)
}
})
)
}
}

View File

@ -0,0 +1,205 @@
/**
* @author Yosuke Ota
*/
'use strict'
const { isParenthesized } = require('@eslint-community/eslint-utils')
const { wrapStylisticOrCoreRule } = require('../utils')
const { getStyleVariablesContext } = require('../utils/style-variables')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = wrapStylisticOrCoreRule('no-extra-parens', {
skipDynamicArguments: true,
applyDocument: true,
create: createForVueSyntax
})
/**
* Check whether the given token is a left parenthesis.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a left parenthesis.
*/
function isLeftParen(token) {
return token.type === 'Punctuator' && token.value === '('
}
/**
* Check whether the given token is a right parenthesis.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a right parenthesis.
*/
function isRightParen(token) {
return token.type === 'Punctuator' && token.value === ')'
}
/**
* Check whether the given token is a left brace.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a left brace.
*/
function isLeftBrace(token) {
return token.type === 'Punctuator' && token.value === '{'
}
/**
* Check whether the given token is a right brace.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a right brace.
*/
function isRightBrace(token) {
return token.type === 'Punctuator' && token.value === '}'
}
/**
* Check whether the given token is a left bracket.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a left bracket.
*/
function isLeftBracket(token) {
return token.type === 'Punctuator' && token.value === '['
}
/**
* Check whether the given token is a right bracket.
* @param {Token} token The token to check.
* @returns {boolean} `true` if the token is a right bracket.
*/
function isRightBracket(token) {
return token.type === 'Punctuator' && token.value === ']'
}
/**
* Determines if a given expression node is an IIFE
* @param {Expression} node The node to check
* @returns {node is CallExpression & { callee: FunctionExpression } } `true` if the given node is an IIFE
*/
function isIIFE(node) {
return (
node.type === 'CallExpression' && node.callee.type === 'FunctionExpression'
)
}
/**
* @param {RuleContext} context - The rule context.
* @returns {TemplateListener} AST event handlers.
*/
function createForVueSyntax(context) {
const sourceCode = context.getSourceCode()
if (!sourceCode.parserServices.getTemplateBodyTokenStore) {
return {}
}
const tokenStore = sourceCode.parserServices.getTemplateBodyTokenStore()
/**
* Checks if the given node turns into a filter when unwraped.
* @param {Expression} expression node to evaluate
* @returns {boolean} `true` if the given node turns into a filter when unwraped.
*/
function isUnwrapChangeToFilter(expression) {
let parenStack = null
for (const token of tokenStore.getTokens(expression)) {
if (parenStack) {
if (parenStack.isUpToken(token)) {
parenStack = parenStack.upper
continue
}
} else {
if (token.value === '|') {
return true
}
}
if (isLeftParen(token)) {
parenStack = { isUpToken: isRightParen, upper: parenStack }
} else if (isLeftBracket(token)) {
parenStack = { isUpToken: isRightBracket, upper: parenStack }
} else if (isLeftBrace(token)) {
parenStack = { isUpToken: isRightBrace, upper: parenStack }
}
}
return false
}
/**
* Checks if the given node is CSS v-bind() without quote.
* @param {VExpressionContainer} node
* @param {Expression} expression
*/
function isStyleVariableWithoutQuote(node, expression) {
const styleVars = getStyleVariablesContext(context)
if (!styleVars || !styleVars.vBinds.includes(node)) {
return false
}
const vBindToken = tokenStore.getFirstToken(node)
const tokens = tokenStore.getTokensBetween(vBindToken, expression)
return tokens.every(isLeftParen)
}
/**
* @param {VExpressionContainer & { expression: Expression | VFilterSequenceExpression | null }} node
*/
function verify(node) {
if (!node.expression) {
return
}
const expression =
node.expression.type === 'VFilterSequenceExpression'
? node.expression.expression
: node.expression
if (!isParenthesized(expression, tokenStore)) {
return
}
if (!isParenthesized(2, expression, tokenStore)) {
if (
isIIFE(expression) &&
!isParenthesized(expression.callee, tokenStore)
) {
return
}
if (isUnwrapChangeToFilter(expression)) {
return
}
if (isStyleVariableWithoutQuote(node, expression)) {
return
}
}
report(expression)
}
/**
* Report the node
* @param {Expression} node node to evaluate
* @returns {void}
* @private
*/
function report(node) {
const sourceCode = context.getSourceCode()
const leftParenToken = tokenStore.getTokenBefore(node)
const rightParenToken = tokenStore.getTokenAfter(node)
context.report({
node,
loc: leftParenToken.loc,
messageId: 'unexpected',
fix(fixer) {
const parenthesizedSource = sourceCode.text.slice(
leftParenToken.range[1],
rightParenToken.range[0]
)
return fixer.replaceTextRange(
[leftParenToken.range[0], rightParenToken.range[1]],
parenthesizedSource
)
}
})
}
return {
"VAttribute[directive=true][key.name.name='bind'] > VExpressionContainer":
verify,
'VElement > VExpressionContainer': verify
}
}

View File

@ -0,0 +1,24 @@
'use strict'
const baseRule = require('./valid-model-definition')
module.exports = {
// eslint-disable-next-line eslint-plugin/prefer-message-ids
meta: {
...baseRule.meta,
// eslint-disable-next-line eslint-plugin/meta-property-ordering
type: baseRule.meta.type,
docs: {
description: baseRule.meta.docs.description,
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-invalid-model-keys.html'
},
schema: [],
deprecated: true,
replacedBy: ['valid-model-definition']
},
/** @param {RuleContext} context */
create(context) {
return baseRule.create(context)
}
}

View File

@ -0,0 +1,249 @@
/**
* @author Yosuke Ota
* @fileoverview Rule to disalow whitespace that is not a tab or space, whitespace inside strings and comments are allowed
*/
'use strict'
const utils = require('../utils')
const ALL_IRREGULARS =
/[\f\v\u0085\uFEFF\u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u200B\u202F\u205F\u3000\u2028\u2029]/u
const IRREGULAR_WHITESPACE =
/[\f\v\u0085\uFEFF\u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u200B\u202F\u205F\u3000]+/gmu
const IRREGULAR_LINE_TERMINATORS = /[\u2028\u2029]/gmu
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow irregular whitespace in `.vue` files',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-irregular-whitespace.html',
extensionSource: {
url: 'https://eslint.org/docs/rules/no-irregular-whitespace',
name: 'ESLint core'
}
},
schema: [
{
type: 'object',
properties: {
skipComments: {
type: 'boolean',
default: false
},
skipStrings: {
type: 'boolean',
default: true
},
skipTemplates: {
type: 'boolean',
default: false
},
skipRegExps: {
type: 'boolean',
default: false
},
skipHTMLAttributeValues: {
type: 'boolean',
default: false
},
skipHTMLTextContents: {
type: 'boolean',
default: false
}
},
additionalProperties: false
}
],
messages: {
disallow: 'Irregular whitespace not allowed.'
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
// Module store of error indexes that we have found
/** @type {number[]} */
let errorIndexes = []
// Lookup the `skipComments` option, which defaults to `false`.
const options = context.options[0] || {}
const skipComments = !!options.skipComments
const skipStrings = options.skipStrings !== false
const skipRegExps = !!options.skipRegExps
const skipTemplates = !!options.skipTemplates
const skipHTMLAttributeValues = !!options.skipHTMLAttributeValues
const skipHTMLTextContents = !!options.skipHTMLTextContents
const sourceCode = context.getSourceCode()
/**
* Removes errors that occur inside a string node
* @param {ASTNode | Token} node to check for matching errors.
* @returns {void}
* @private
*/
function removeWhitespaceError(node) {
const [startIndex, endIndex] = node.range
errorIndexes = errorIndexes.filter(
(errorIndex) => errorIndex < startIndex || endIndex <= errorIndex
)
}
/**
* Checks literal nodes for errors that we are choosing to ignore and calls the relevant methods to remove the errors
* @param {Literal} node to check for matching errors.
* @returns {void}
* @private
*/
function removeInvalidNodeErrorsInLiteral(node) {
const shouldCheckStrings = skipStrings && typeof node.value === 'string'
const shouldCheckRegExps = skipRegExps && Boolean(node.regex)
// If we have irregular characters, remove them from the errors list
if (
(shouldCheckStrings || shouldCheckRegExps) &&
ALL_IRREGULARS.test(sourceCode.getText(node))
) {
removeWhitespaceError(node)
}
}
/**
* Checks template string literal nodes for errors that we are choosing to ignore and calls the relevant methods to remove the errors
* @param {TemplateElement} node to check for matching errors.
* @returns {void}
* @private
*/
function removeInvalidNodeErrorsInTemplateLiteral(node) {
if (ALL_IRREGULARS.test(node.value.raw)) {
removeWhitespaceError(node)
}
}
/**
* Checks HTML attribute value nodes for errors that we are choosing to ignore and calls the relevant methods to remove the errors
* @param {VLiteral} node to check for matching errors.
* @returns {void}
* @private
*/
function removeInvalidNodeErrorsInHTMLAttributeValue(node) {
if (ALL_IRREGULARS.test(sourceCode.getText(node))) {
removeWhitespaceError(node)
}
}
/**
* Checks HTML text content nodes for errors that we are choosing to ignore and calls the relevant methods to remove the errors
* @param {VText} node to check for matching errors.
* @returns {void}
* @private
*/
function removeInvalidNodeErrorsInHTMLTextContent(node) {
if (ALL_IRREGULARS.test(sourceCode.getText(node))) {
removeWhitespaceError(node)
}
}
/**
* Checks comment nodes for errors that we are choosing to ignore and calls the relevant methods to remove the errors
* @param {Comment | HTMLComment | HTMLBogusComment} node to check for matching errors.
* @returns {void}
* @private
*/
function removeInvalidNodeErrorsInComment(node) {
if (ALL_IRREGULARS.test(node.value)) {
removeWhitespaceError(node)
}
}
/**
* Checks the program source for irregular whitespaces and irregular line terminators
* @returns {void}
* @private
*/
function checkForIrregularWhitespace() {
const source = sourceCode.getText()
let match
while ((match = IRREGULAR_WHITESPACE.exec(source)) !== null) {
errorIndexes.push(match.index)
}
while ((match = IRREGULAR_LINE_TERMINATORS.exec(source)) !== null) {
errorIndexes.push(match.index)
}
}
checkForIrregularWhitespace()
if (errorIndexes.length === 0) {
return {}
}
const bodyVisitor = utils.defineTemplateBodyVisitor(context, {
...(skipHTMLAttributeValues
? {
'VAttribute[directive=false] > VLiteral':
removeInvalidNodeErrorsInHTMLAttributeValue
}
: {}),
...(skipHTMLTextContents
? { VText: removeInvalidNodeErrorsInHTMLTextContent }
: {}),
// inline scripts
Literal: removeInvalidNodeErrorsInLiteral,
...(skipTemplates
? { TemplateElement: removeInvalidNodeErrorsInTemplateLiteral }
: {})
})
return {
...bodyVisitor,
Literal: removeInvalidNodeErrorsInLiteral,
...(skipTemplates
? { TemplateElement: removeInvalidNodeErrorsInTemplateLiteral }
: {}),
'Program:exit'(node) {
if (bodyVisitor['Program:exit']) {
bodyVisitor['Program:exit'](node)
}
const templateBody = node.templateBody
if (skipComments) {
// First strip errors occurring in comment nodes.
for (const node of sourceCode.getAllComments()) {
removeInvalidNodeErrorsInComment(node)
}
if (templateBody) {
for (const node of templateBody.comments) {
removeInvalidNodeErrorsInComment(node)
}
}
}
// Removes errors that occur outside script and template
const [scriptStart, scriptEnd] = node.range
const [templateStart, templateEnd] = templateBody
? templateBody.range
: [0, 0]
errorIndexes = errorIndexes.filter(
(errorIndex) =>
(scriptStart <= errorIndex && errorIndex < scriptEnd) ||
(templateStart <= errorIndex && errorIndex < templateEnd)
)
// If we have any errors remaining, report on them
for (const errorIndex of errorIndexes) {
context.report({
loc: sourceCode.getLocFromIndex(errorIndex),
messageId: 'disallow'
})
}
}
}
}
}

View File

@ -0,0 +1,144 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const { ReferenceTracker } = require('@eslint-community/eslint-utils')
const utils = require('../utils')
/**
* @typedef {import('@eslint-community/eslint-utils').TYPES.TraceMap} TraceMap
*/
const LIFECYCLE_HOOKS = [
'onBeforeMount',
'onBeforeUnmount',
'onBeforeUpdate',
'onErrorCaptured',
'onMounted',
'onRenderTracked',
'onRenderTriggered',
'onUnmounted',
'onUpdated',
'onActivated',
'onDeactivated'
]
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow asynchronously registered lifecycle hooks',
categories: ['vue3-essential'],
url: 'https://eslint.vuejs.org/rules/no-lifecycle-after-await.html'
},
fixable: null,
schema: [],
messages: {
forbidden: 'Lifecycle hooks are forbidden after an `await` expression.'
}
},
/** @param {RuleContext} context */
create(context) {
/**
* @typedef {object} SetupScopeData
* @property {boolean} afterAwait
* @property {[number,number]} range
*/
/**
* @typedef {object} ScopeStack
* @property {ScopeStack | null} upper
* @property {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} scopeNode
*/
/** @type {Set<ESNode>} */
const lifecycleHookCallNodes = new Set()
/** @type {Map<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression, SetupScopeData>} */
const setupScopes = new Map()
/** @type {ScopeStack | null} */
let scopeStack = null
return utils.compositingVisitors(
{
/** @param {Program} program */
Program(program) {
const tracker = new ReferenceTracker(utils.getScope(context, program))
const traceMap = {
/** @type {TraceMap} */
vue: {
[ReferenceTracker.ESM]: true
}
}
for (const lifecycleHook of LIFECYCLE_HOOKS) {
traceMap.vue[lifecycleHook] = {
[ReferenceTracker.CALL]: true
}
}
for (const { node } of tracker.iterateEsmReferences(traceMap)) {
lifecycleHookCallNodes.add(node)
}
}
},
utils.defineVueVisitor(context, {
onSetupFunctionEnter(node) {
setupScopes.set(node, {
afterAwait: false,
range: node.range
})
},
/**
* @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
*/
':function'(node) {
scopeStack = {
upper: scopeStack,
scopeNode: node
}
},
':function:exit'() {
scopeStack = scopeStack && scopeStack.upper
},
/** @param {AwaitExpression} node */
AwaitExpression(node) {
if (!scopeStack) {
return
}
const setupScope = setupScopes.get(scopeStack.scopeNode)
if (!setupScope || !utils.inRange(setupScope.range, node)) {
return
}
setupScope.afterAwait = true
},
/** @param {CallExpression} node */
CallExpression(node) {
if (!scopeStack) {
return
}
const setupScope = setupScopes.get(scopeStack.scopeNode)
if (
!setupScope ||
!setupScope.afterAwait ||
!utils.inRange(setupScope.range, node)
) {
return
}
if (lifecycleHookCallNodes.has(node)) {
if (node.arguments.length >= 2) {
// Has target instance. e.g. `onMounted(() => {}, instance)`
return
}
context.report({
node,
messageId: 'forbidden'
})
}
},
onSetupFunctionExit(node) {
setupScopes.delete(node)
}
})
)
}
}

View File

@ -0,0 +1,117 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
// https://github.com/vuejs/vue-next/blob/64e2f4643602c5980361e66674141e61ba60ef70/packages/compiler-core/src/parse.ts#L405
const SPECIAL_TEMPLATE_DIRECTIVES = new Set([
'if',
'else',
'else-if',
'for',
'slot'
])
/**
* @param {VAttribute | VDirective} attr
*/
function getKeyName(attr) {
if (attr.directive) {
if (attr.key.name.name !== 'bind') {
// no v-bind
return null
}
if (
!attr.key.argument ||
attr.key.argument.type === 'VExpressionContainer'
) {
// unknown
return null
}
return attr.key.argument.name
}
return attr.key.name
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow unnecessary `<template>`',
categories: ['vue3-recommended', 'recommended'],
url: 'https://eslint.vuejs.org/rules/no-lone-template.html'
},
fixable: null,
schema: [
{
type: 'object',
properties: {
ignoreAccessible: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
requireDirective: '`<template>` require directive.'
}
},
/** @param {RuleContext} context */
create(context) {
const options = context.options[0] || {}
const ignoreAccessible = options.ignoreAccessible === true
return utils.defineTemplateBodyVisitor(context, {
/** @param {VStartTag} node */
"VElement[name='template'][parent.type='VElement'] > VStartTag"(node) {
if (
node.attributes.some((attr) => {
if (attr.directive) {
const directiveName = attr.key.name.name
if (SPECIAL_TEMPLATE_DIRECTIVES.has(directiveName)) {
return true
}
if (directiveName === 'slot-scope') {
// `slot-scope` is deprecated in Vue.js 2.6
return true
}
if (directiveName === 'scope') {
// `scope` is deprecated in Vue.js 2.5
return true
}
}
const keyName = getKeyName(attr)
if (keyName === 'slot') {
// `slot` is deprecated in Vue.js 2.6
return true
}
return false
})
) {
return
}
if (
ignoreAccessible &&
node.attributes.some((attr) => {
const keyName = getKeyName(attr)
return keyName === 'id' || keyName === 'ref'
})
) {
return
}
context.report({
node,
messageId: 'requireDirective'
})
}
})
}
}

View File

@ -0,0 +1,12 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'
const utils = require('../utils')
// eslint-disable-next-line internal/no-invalid-meta
module.exports = utils.wrapCoreRule('no-loss-of-precision', {
applyDocument: true
})

View File

@ -0,0 +1,107 @@
/**
* @fileoverview This rule warns about the usage of extra whitespaces between attributes
* @author Armano
*/
'use strict'
const path = require('path')
/**
* @param {RuleContext} context
* @param {Token} node
*/
const isProperty = (context, node) => {
const sourceCode = context.getSourceCode()
return node.type === 'Punctuator' && sourceCode.getText(node) === ':'
}
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'disallow multiple spaces',
categories: ['vue3-strongly-recommended', 'strongly-recommended'],
url: 'https://eslint.vuejs.org/rules/no-multi-spaces.html'
},
fixable: 'whitespace',
schema: [
{
type: 'object',
properties: {
ignoreProperties: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
multipleSpaces: "Multiple spaces found before '{{displayValue}}'.",
useLatestParser:
'Use the latest vue-eslint-parser. See also https://eslint.vuejs.org/user-guide/#what-is-the-use-the-latest-vue-eslint-parser-error.'
}
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
const options = context.options[0] || {}
const ignoreProperties = options.ignoreProperties === true
return {
Program(node) {
const sourceCode = context.getSourceCode()
if (sourceCode.parserServices.getTemplateBodyTokenStore == null) {
const filename = context.getFilename()
if (path.extname(filename) === '.vue') {
context.report({
loc: { line: 1, column: 0 },
messageId: 'useLatestParser'
})
}
return
}
if (!node.templateBody) {
return
}
const tokenStore = sourceCode.parserServices.getTemplateBodyTokenStore()
const tokens = tokenStore.getTokens(node.templateBody, {
includeComments: true
})
let prevToken = /** @type {Token} */ (tokens.shift())
for (const token of tokens) {
const spaces = token.range[0] - prevToken.range[1]
const shouldIgnore =
ignoreProperties &&
(isProperty(context, token) || isProperty(context, prevToken))
if (
spaces > 1 &&
token.loc.start.line === prevToken.loc.start.line &&
!shouldIgnore
) {
context.report({
node: token,
loc: {
start: prevToken.loc.end,
end: token.loc.start
},
messageId: 'multipleSpaces',
fix: (fixer) =>
fixer.replaceTextRange(
[prevToken.range[1], token.range[0]],
' '
),
data: {
displayValue: sourceCode.getText(token)
}
})
}
prevToken = token
}
}
}
}
}

View File

@ -0,0 +1,51 @@
/**
* @author tyankatsu <https://github.com/tyankatsu0105>
* See LICENSE file in root directory for full license.
*/
'use strict'
const { defineTemplateBodyVisitor } = require('../utils')
/**
* count ObjectExpression element
* @param {VDirective & {value: VExpressionContainer & {expression: ArrayExpression}}} node
* @return {number}
*/
function countObjectExpression(node) {
return node.value.expression.elements.filter(
(element) => element && element.type === 'ObjectExpression'
).length
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow to pass multiple objects into array to class',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-multiple-objects-in-class.html'
},
fixable: null,
schema: [],
messages: {
unexpected: 'Unexpected multiple objects. Merge objects.'
}
},
/** @param {RuleContext} context */
create(context) {
return defineTemplateBodyVisitor(context, {
/** @param {VDirective & {value: VExpressionContainer & {expression: ArrayExpression}}} node */
'VAttribute[directive=true][key.argument.name="class"][key.name.name="bind"][value.expression.type="ArrayExpression"]'(
node
) {
if (countObjectExpression(node) > 1) {
context.report({
node,
loc: node.loc,
messageId: 'unexpected'
})
}
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More