You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

319 lines
10 KiB

/**
* @author Felipe Melendez
* See LICENSE file in root directory for full license.
*/
'use strict'
// =============================================================================
// Requirements
// =============================================================================
const utils = require('../utils')
const casing = require('../utils/casing')
// =============================================================================
// Rule Helpers
// =============================================================================
/**
* A conditional family is made up of a group of repeated components that are conditionally rendered
* using v-if, v-else-if, and v-else.
*
* @typedef {Object} ConditionalFamily
* @property {VElement} if - The node associated with the 'v-if' directive.
* @property {VElement[]} elseIf - An array of nodes associated with 'v-else-if' directives.
* @property {VElement | null} else - The node associated with the 'v-else' directive, or null if there isn't one.
*/
/**
* Checks if a given node has sibling nodes of the same type that are also conditionally rendered.
* This is used to determine if multiple instances of the same component are being conditionally
* rendered within the same parent scope.
*
* @param {VElement} node - The Vue component node to check for conditional rendering siblings.
* @param {string} componentName - The name of the component to check for sibling instances.
* @returns {boolean} True if there are sibling nodes of the same type and conditionally rendered, false otherwise.
*/
const hasConditionalRenderedSiblings = (node, componentName) => {
if (!node.parent || node.parent.type !== 'VElement') {
return false
}
return node.parent.children.some(
(sibling) =>
sibling !== node &&
sibling.type === 'VElement' &&
sibling.rawName === componentName &&
hasConditionalDirective(sibling)
)
}
/**
* Checks for the presence of a 'key' attribute in the given node. If the 'key' attribute is missing
* and the node is part of a conditional family a report is generated.
* The fix proposed adds a unique key based on the component's name and count,
* following the format '${kebabCase(componentName)}-${componentCount}', e.g., 'some-component-2'.
*
* @param {VElement} node - The Vue component node to check for a 'key' attribute.
* @param {RuleContext} context - The rule's context object, used for reporting.
* @param {string} componentName - Name of the component.
* @param {string} uniqueKey - A unique key for the repeated component, used for the fix.
* @param {Map<VElement, ConditionalFamily>} conditionalFamilies - Map of conditionally rendered components and their respective conditional directives.
*/
const checkForKey = (
node,
context,
componentName,
uniqueKey,
conditionalFamilies
) => {
if (
!node.parent ||
node.parent.type !== 'VElement' ||
!hasConditionalRenderedSiblings(node, componentName)
) {
return
}
const conditionalFamily = conditionalFamilies.get(node.parent)
if (!conditionalFamily || utils.hasAttribute(node, 'key')) {
return
}
const needsKey =
conditionalFamily.if === node ||
conditionalFamily.else === node ||
conditionalFamily.elseIf.includes(node)
if (needsKey) {
context.report({
node: node.startTag,
loc: node.startTag.loc,
messageId: 'requireKey',
data: { componentName },
fix(fixer) {
const afterComponentNamePosition =
node.startTag.range[0] + componentName.length + 1
return fixer.insertTextBeforeRange(
[afterComponentNamePosition, afterComponentNamePosition],
` key="${uniqueKey}"`
)
}
})
}
}
/**
* Checks for the presence of conditional directives in the given node.
*
* @param {VElement} node - The node to check for conditional directives.
* @returns {boolean} Returns true if a conditional directive is found in the node or its parents,
* false otherwise.
*/
const hasConditionalDirective = (node) =>
utils.hasDirective(node, 'if') ||
utils.hasDirective(node, 'else-if') ||
utils.hasDirective(node, 'else')
// =============================================================================
// Rule Definition
// =============================================================================
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'require key attribute for conditionally rendered repeated components',
categories: null,
recommended: false,
url: 'https://eslint.vuejs.org/rules/v-if-else-key.html'
},
// eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized
fixable: 'code',
schema: [],
messages: {
requireKey:
"Conditionally rendered repeated component '{{componentName}}' expected to have a 'key' attribute."
}
},
/**
* Creates and returns a rule object which checks usage of repeated components. If a component
* is used more than once, it checks for the presence of a key.
*
* @param {RuleContext} context - The context object.
* @returns {Object} A dictionary of functions to be called on traversal of the template body by
* the eslint parser.
*/
create(context) {
/**
* Map to store conditionally rendered components and their respective conditional directives.
*
* @type {Map<VElement, ConditionalFamily>}
*/
const conditionalFamilies = new Map()
/**
* Array of Maps to keep track of components and their usage counts along with the first
* node instance. Each Map represents a different scope level, and maps a component name to
* an object containing the count and a reference to the first node.
*/
/** @type {Map<string, { count: number; firstNode: any }>[]} */
const componentUsageStack = [new Map()]
/**
* Checks if a given node represents a custom component without any conditional directives.
*
* @param {VElement} node - The AST node to check.
* @returns {boolean} True if the node represents a custom component without any conditional directives, false otherwise.
*/
const isCustomComponentWithoutCondition = (node) =>
node.type === 'VElement' &&
utils.isCustomComponent(node) &&
!hasConditionalDirective(node)
/** Set of built-in Vue components that are exempt from the rule. */
/** @type {Set<string>} */
const exemptTags = new Set(['component', 'slot', 'template'])
/** Set to keep track of nodes we've pushed to the stack. */
/** @type {Set<any>} */
const pushedNodes = new Set()
/**
* Creates and returns an object representing a conditional family.
*
* @param {VElement} ifNode - The VElement associated with the 'v-if' directive.
* @returns {ConditionalFamily}
*/
const createConditionalFamily = (ifNode) => ({
if: ifNode,
elseIf: [],
else: null
})
return utils.defineTemplateBodyVisitor(context, {
/**
* Callback to be executed when a Vue element is traversed. This function checks if the
* element is a component, increments the usage count of the component in the
* current scope, and checks for the key directive if the component is repeated.
*
* @param {VElement} node - The traversed Vue element.
*/
VElement(node) {
if (exemptTags.has(node.rawName)) {
return
}
const condition =
utils.getDirective(node, 'if') ||
utils.getDirective(node, 'else-if') ||
utils.getDirective(node, 'else')
if (condition) {
const conditionType = condition.key.name.name
if (node.parent && node.parent.type === 'VElement') {
let conditionalFamily = conditionalFamilies.get(node.parent)
if (!conditionalFamily) {
conditionalFamily = createConditionalFamily(node)
conditionalFamilies.set(node.parent, conditionalFamily)
}
if (conditionalFamily) {
switch (conditionType) {
case 'if': {
conditionalFamily = createConditionalFamily(node)
conditionalFamilies.set(node.parent, conditionalFamily)
break
}
case 'else-if': {
conditionalFamily.elseIf.push(node)
break
}
case 'else': {
conditionalFamily.else = node
break
}
}
}
}
}
if (isCustomComponentWithoutCondition(node)) {
componentUsageStack.push(new Map())
return
}
if (!utils.isCustomComponent(node)) {
return
}
const componentName = node.rawName
const currentScope = componentUsageStack[componentUsageStack.length - 1]
const usageInfo = currentScope.get(componentName) || {
count: 0,
firstNode: null
}
if (hasConditionalDirective(node)) {
// Store the first node if this is the first occurrence
if (usageInfo.count === 0) {
usageInfo.firstNode = node
}
if (usageInfo.count > 0) {
const uniqueKey = `${casing.kebabCase(componentName)}-${
usageInfo.count + 1
}`
checkForKey(
node,
context,
componentName,
uniqueKey,
conditionalFamilies
)
// If this is the second occurrence, also apply a fix to the first occurrence
if (usageInfo.count === 1) {
const uniqueKeyForFirstInstance = `${casing.kebabCase(
componentName
)}-1`
checkForKey(
usageInfo.firstNode,
context,
componentName,
uniqueKeyForFirstInstance,
conditionalFamilies
)
}
}
usageInfo.count += 1
currentScope.set(componentName, usageInfo)
}
componentUsageStack.push(new Map())
pushedNodes.add(node)
},
'VElement:exit'(node) {
if (exemptTags.has(node.rawName)) {
return
}
if (isCustomComponentWithoutCondition(node)) {
componentUsageStack.pop()
return
}
if (!utils.isCustomComponent(node)) {
return
}
if (pushedNodes.has(node)) {
componentUsageStack.pop()
pushedNodes.delete(node)
}
}
})
}
}