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.
318 lines
10 KiB
318 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) |
|
} |
|
} |
|
}) |
|
} |
|
}
|
|
|