diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md index 2a165ec19..ba33eadf0 100644 --- a/docs/rules/require-explicit-emits.md +++ b/docs/rules/require-explicit-emits.md @@ -74,7 +74,37 @@ export default { ## :wrench: Options -Nothing. +```json +{ + "vue/require-explicit-emits": ["error", { + "allowProps": false + }] +} +``` + +- `"allowProps"` ... If `true`, allow event names defined in `props`. default `false` + +### `"allowProps": true` + + + +```vue + +``` + + ## :books: Further Reading diff --git a/lib/rules/no-reserved-component-names.js b/lib/rules/no-reserved-component-names.js index e21bf7cd8..aecbe6020 100644 --- a/lib/rules/no-reserved-component-names.js +++ b/lib/rules/no-reserved-component-names.js @@ -37,14 +37,10 @@ const vue3BuiltInComponents = ['teleport', 'suspense'] function isLowercase(word) { return /^[a-z]*$/.test(word) } -/** @param {string} word */ -function capitalizeFirstLetter(word) { - return word[0].toUpperCase() + word.substring(1, word.length) -} const RESERVED_NAMES_IN_HTML = new Set([ ...htmlElements, - ...htmlElements.map(capitalizeFirstLetter) + ...htmlElements.map(casing.capitalize) ]) const RESERVED_NAMES_IN_VUE = new Set([ ...vueBuiltInComponents, @@ -57,11 +53,11 @@ const RESERVED_NAMES_IN_VUE3 = new Set([ ]) const RESERVED_NAMES_IN_OTHERS = new Set([ ...deprecatedHtmlElements, - ...deprecatedHtmlElements.map(capitalizeFirstLetter), + ...deprecatedHtmlElements.map(casing.capitalize), ...kebabCaseElements, ...kebabCaseElements.map(casing.pascalCase), ...svgElements, - ...svgElements.filter(isLowercase).map(capitalizeFirstLetter) + ...svgElements.filter(isLowercase).map(casing.capitalize) ]) // ------------------------------------------------------------------------------ diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js index fdadc2332..ec356364e 100644 --- a/lib/rules/require-explicit-emits.js +++ b/lib/rules/require-explicit-emits.js @@ -7,6 +7,8 @@ /** * @typedef {import('../utils').ComponentArrayEmit} ComponentArrayEmit * @typedef {import('../utils').ComponentObjectEmit} ComponentObjectEmit + * @typedef {import('../utils').ComponentArrayProp} ComponentArrayProp + * @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp * @typedef {import('../utils').VueObjectData} VueObjectData */ @@ -16,6 +18,7 @@ const { findVariable } = require('eslint-utils') const utils = require('../utils') +const { capitalize } = require('../utils/casing') // ------------------------------------------------------------------------------ // Helpers @@ -89,7 +92,17 @@ module.exports = { url: 'https://eslint.vuejs.org/rules/require-explicit-emits.html' }, fixable: null, - schema: [], + schema: [ + { + type: 'object', + properties: { + allowProps: { + type: 'boolean' + } + }, + additionalProperties: false + } + ], messages: { missing: 'The "{{name}}" event has been triggered but not declared on `emits` option.', @@ -102,49 +115,49 @@ module.exports = { }, /** @param {RuleContext} context */ create(context) { - /** @typedef { { node: Literal, name: string } } EmitCellName */ + const options = context.options[0] || {} + const allowProps = !!options.allowProps /** @type {Map, emitReferenceIds: Set }>} */ const setupContexts = new Map() /** @type {Map} */ const vueEmitsDeclarations = new Map() - - /** @type {EmitCellName[]} */ - const templateEmitCellNames = [] - /** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression, emits: (ComponentArrayEmit | ComponentObjectEmit)[] } | null } */ - let vueObjectData = null + /** @type {Map} */ + const vuePropsDeclarations = new Map() /** - * @param {Literal} nameLiteralNode + * @typedef {object} VueTemplateObjectData + * @property {'export' | 'mark' | 'definition'} type + * @property {ObjectExpression} object + * @property {(ComponentArrayEmit | ComponentObjectEmit)[]} emits + * @property {(ComponentArrayProp | ComponentObjectProp)[]} props */ - function addTemplateEmitCellName(nameLiteralNode) { - templateEmitCellNames.push({ - node: nameLiteralNode, - name: `${nameLiteralNode.value}` - }) - } + /** @type {VueTemplateObjectData | null} */ + let vueTemplateObjectData = null /** - * @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emitsDeclarations + * @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emits + * @param {(ComponentArrayProp | ComponentObjectProp)[]} props * @param {Literal} nameLiteralNode * @param {ObjectExpression} vueObjectNode */ - function verify(emitsDeclarations, nameLiteralNode, vueObjectNode) { + function verifyEmit(emits, props, nameLiteralNode, vueObjectNode) { const name = `${nameLiteralNode.value}` - if (emitsDeclarations.some((e) => e.emitName === name)) { + if (emits.some((e) => e.emitName === name)) { return } + if (allowProps) { + const key = `on${capitalize(name)}` + if (props.some((e) => e.propName === key)) { + return + } + } context.report({ node: nameLiteralNode, messageId: 'missing', data: { name }, - suggest: buildSuggest( - vueObjectNode, - emitsDeclarations, - nameLiteralNode, - context - ) + suggest: buildSuggest(vueObjectNode, emits, nameLiteralNode, context) }) } @@ -153,47 +166,31 @@ module.exports = { { /** @param { CallExpression & { argument: [Literal, ...Expression] } } node */ 'CallExpression[arguments.0.type=Literal]'(node) { - const callee = node.callee + const callee = utils.skipChainExpression(node.callee) const nameLiteralNode = /** @type {Literal} */ (node.arguments[0]) if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') { // cannot check return } - if (callee.type === 'Identifier' && callee.name === '$emit') { - addTemplateEmitCellName(nameLiteralNode) - } - }, - "VElement[parent.type!='VElement']:exit"() { - if (!vueObjectData) { + if (!vueTemplateObjectData) { return } - const emitsDeclarationNames = new Set( - vueObjectData.emits.map((e) => e.emitName) - ) - - for (const { name, node } of templateEmitCellNames) { - if (emitsDeclarationNames.has(name)) { - continue - } - context.report({ - node, - messageId: 'missing', - data: { - name - }, - suggest: buildSuggest( - vueObjectData.object, - vueObjectData.emits, - node, - context - ) - }) + if (callee.type === 'Identifier' && callee.name === '$emit') { + verifyEmit( + vueTemplateObjectData.emits, + vueTemplateObjectData.props, + nameLiteralNode, + vueTemplateObjectData.object + ) } } }, utils.defineVueVisitor(context, { onVueObjectEnter(node) { vueEmitsDeclarations.set(node, utils.getComponentEmits(node)) + if (allowProps) { + vuePropsDeclarations.set(node, utils.getComponentProps(node)) + } }, onSetupFunctionEnter(node, { node: vueNode }) { const contextParam = node.params[1] @@ -286,7 +283,12 @@ module.exports = { const { contextReferenceIds, emitReferenceIds } = setupContext if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { // verify setup(props,{emit}) {emit()} - verify(emitsDeclarations, nameLiteralNode, vueNode) + verifyEmit( + emitsDeclarations, + vuePropsDeclarations.get(vueNode) || [], + nameLiteralNode, + vueNode + ) } else if (emit && emit.name === 'emit') { const memObject = utils.skipChainExpression(emit.member.object) if ( @@ -294,7 +296,12 @@ module.exports = { contextReferenceIds.has(memObject) ) { // verify setup(props,context) {context.emit()} - verify(emitsDeclarations, nameLiteralNode, vueNode) + verifyEmit( + emitsDeclarations, + vuePropsDeclarations.get(vueNode) || [], + nameLiteralNode, + vueNode + ) } } } @@ -304,26 +311,36 @@ module.exports = { const memObject = utils.skipChainExpression(emit.member.object) if (utils.isThis(memObject, context)) { // verify this.$emit() - verify(emitsDeclarations, nameLiteralNode, vueNode) + verifyEmit( + emitsDeclarations, + vuePropsDeclarations.get(vueNode) || [], + nameLiteralNode, + vueNode + ) } } }, onVueObjectExit(node, { type }) { const emits = vueEmitsDeclarations.get(node) - if (!vueObjectData || vueObjectData.type !== 'export') { + if ( + !vueTemplateObjectData || + vueTemplateObjectData.type !== 'export' + ) { if ( emits && (type === 'mark' || type === 'export' || type === 'definition') ) { - vueObjectData = { + vueTemplateObjectData = { type, object: node, - emits + emits, + props: vuePropsDeclarations.get(node) || [] } } } setupContexts.delete(node) vueEmitsDeclarations.delete(node) + vuePropsDeclarations.delete(node) } }) ) diff --git a/lib/rules/require-valid-default-prop.js b/lib/rules/require-valid-default-prop.js index ffebef07f..ef9453b23 100644 --- a/lib/rules/require-valid-default-prop.js +++ b/lib/rules/require-valid-default-prop.js @@ -4,6 +4,7 @@ */ 'use strict' const utils = require('../utils') +const { capitalize } = require('../utils/casing') /** * @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp @@ -68,13 +69,6 @@ function getTypes(node) { return [] } -/** - * @param {string} text - */ -function capitalize(text) { - return text[0].toUpperCase() + text.slice(1) -} - // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ diff --git a/lib/utils/casing.js b/lib/utils/casing.js index f6dea6d6e..b2f2e89b5 100644 --- a/lib/utils/casing.js +++ b/lib/utils/casing.js @@ -197,5 +197,7 @@ module.exports = { isCamelCase, isPascalCase, isKebabCase, - isSnakeCase + isSnakeCase, + + capitalize } diff --git a/tests/lib/rules/require-explicit-emits.js b/tests/lib/rules/require-explicit-emits.js index d59d78600..4f27563cd 100644 --- a/tests/lib/rules/require-explicit-emits.js +++ b/tests/lib/rules/require-explicit-emits.js @@ -359,6 +359,27 @@ tester.run('require-explicit-emits', rule, { } ` + }, + // allowProps + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }] } ], invalid: [ @@ -1551,6 +1572,41 @@ emits: {'foo': null} 'The "foo" event has been triggered but not declared on `emits` option.', 'The "bar" event has been triggered but not declared on `emits` option.' ] + }, + // allowProps + { + filename: 'test.vue', + code: ` + + + `, + options: [{ allowProps: true }], + errors: [ + { + line: 3, + messageId: 'missing' + }, + { + line: 9, + messageId: 'missing' + }, + { + line: 12, + messageId: 'missing' + } + ] } ] })