Skip to content

Commit 561e88d

Browse files
committed
Add vue/custom-event-name-casing rule
1 parent e004975 commit 561e88d

File tree

7 files changed

+510
-0
lines changed

7 files changed

+510
-0
lines changed

Diff for: docs/rules/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
3838

3939
| Rule ID | Description | |
4040
|:--------|:------------|:---|
41+
| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" | |
4142
| [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher | |
4243
| [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties | |
4344
| [vue/no-deprecated-data-object-declaration](./no-deprecated-data-object-declaration.md) | disallow using deprecated object declaration on data (in Vue.js 3.0.0+) | :wrench: |
@@ -160,6 +161,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
160161

161162
| Rule ID | Description | |
162163
|:--------|:------------|:---|
164+
| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" | |
163165
| [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher | |
164166
| [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties | |
165167
| [vue/no-custom-modifiers-on-v-model](./no-custom-modifiers-on-v-model.md) | disallow custom modifiers on v-model used on the component | |

Diff for: docs/rules/custom-event-name-casing.md

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
pageClass: rule-details
3+
sidebarDepth: 0
4+
title: vue/custom-event-name-casing
5+
description: enforce custom event names always use "kebab-case"
6+
---
7+
# vue/custom-event-name-casing
8+
> enforce custom event names always use "kebab-case"
9+
10+
- :gear: This rule is included in all of `"plugin:vue/vue3-essential"`, `"plugin:vue/essential"`, `"plugin:vue/vue3-strongly-recommended"`, `"plugin:vue/strongly-recommended"`, `"plugin:vue/vue3-recommended"` and `"plugin:vue/recommended"`.
11+
12+
## :book: Rule Details
13+
14+
This rule enforces using kebab-case custom event names.
15+
16+
> Event names will never be used as variable or property names in JavaScript, so there’s no reason to use camelCase or PascalCase. Additionally, `v-on` event listeners inside DOM templates will be automatically transformed to lowercase (due to HTML’s case-insensitivity), so `v-on:myEvent` would become `v-on:myevent` – making `myEvent` impossible to listen to.
17+
>
18+
> For these reasons, we recommend you **always use kebab-case for event names**.
19+
20+
See [Guide - Custom Events] for more details.
21+
22+
<eslint-code-block :rules="{'vue/custom-event-name-casing': ['error']}">
23+
24+
```vue
25+
<template>
26+
<!-- ✔ GOOD -->
27+
<button @click="$emit('my-event')" />
28+
29+
<!-- ✘ BAD -->
30+
<button @click="$emit('myEvent')" />
31+
</template>
32+
<script>
33+
export default {
34+
methods: {
35+
onClick () {
36+
/* ✔ GOOD */
37+
this.$emit('my-event')
38+
this.$emit('update:myProp', myProp)
39+
40+
/* ✘ BAD */
41+
this.$emit('myEvent')
42+
}
43+
}
44+
}
45+
</script>
46+
```
47+
48+
</eslint-code-block>
49+
50+
## :wrench: Options
51+
52+
Nothing.
53+
54+
## :books: Further Reading
55+
56+
- [Guide - Custom Events]
57+
58+
[Guide - Custom Events]: https://vuejs.org/v2/guide/components-custom-events.html
59+
60+
## :mag: Implementation
61+
62+
- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/custom-event-name-casing.js)
63+
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/custom-event-name-casing.js)

Diff for: lib/configs/essential.js

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
module.exports = {
77
extends: require.resolve('./base'),
88
rules: {
9+
'vue/custom-event-name-casing': 'error',
910
'vue/no-arrow-functions-in-watch': 'error',
1011
'vue/no-async-in-computed-properties': 'error',
1112
'vue/no-custom-modifiers-on-v-model': 'error',

Diff for: lib/configs/vue3-essential.js

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
module.exports = {
77
extends: require.resolve('./base'),
88
rules: {
9+
'vue/custom-event-name-casing': 'error',
910
'vue/no-arrow-functions-in-watch': 'error',
1011
'vue/no-async-in-computed-properties': 'error',
1112
'vue/no-deprecated-data-object-declaration': 'error',

Diff for: lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ module.exports = {
2121
'component-definition-name-casing': require('./rules/component-definition-name-casing'),
2222
'component-name-in-template-casing': require('./rules/component-name-in-template-casing'),
2323
'component-tags-order': require('./rules/component-tags-order'),
24+
'custom-event-name-casing': require('./rules/custom-event-name-casing'),
2425
'dot-location': require('./rules/dot-location'),
2526
eqeqeq: require('./rules/eqeqeq'),
2627
'html-closing-bracket-newline': require('./rules/html-closing-bracket-newline'),

Diff for: lib/rules/custom-event-name-casing.js

+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* @author Yosuke Ota
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
'use strict'
6+
7+
/**
8+
* @typedef {import('vue-eslint-parser').AST.ESLintLiteral} Literal
9+
* @typedef {import('vue-eslint-parser').AST.ESLintCallExpression} CallExpression
10+
*/
11+
12+
// ------------------------------------------------------------------------------
13+
// Requirements
14+
// ------------------------------------------------------------------------------
15+
16+
const { findVariable } = require('eslint-utils')
17+
const utils = require('../utils')
18+
const { isKebabCase } = require('../utils/casing')
19+
20+
// ------------------------------------------------------------------------------
21+
// Helpers
22+
// ------------------------------------------------------------------------------
23+
24+
/**
25+
* Check whether the given event name is valid.
26+
* @param {string} name The name to check.
27+
* @returns {boolean} `true` if the given event name is valid.
28+
*/
29+
function isValidEventName(name) {
30+
return isKebabCase(name) || name.startsWith('update:')
31+
}
32+
33+
/**
34+
* Get the name param node from the given CallExpression
35+
* @param {CallExpression} node CallExpression
36+
* @returns { Literal & { value: string } }
37+
*/
38+
function getNameParamNode(node) {
39+
const nameLiteralNode = node.arguments[0]
40+
if (
41+
!nameLiteralNode ||
42+
nameLiteralNode.type !== 'Literal' ||
43+
typeof nameLiteralNode.value !== 'string'
44+
) {
45+
// cannot check
46+
return null
47+
}
48+
49+
return nameLiteralNode
50+
}
51+
/**
52+
* Get the callee member node from the given CallExpression
53+
* @param {CallExpression} node CallExpression
54+
*/
55+
function getCalleeMemberNode(node) {
56+
const callee = node.callee
57+
58+
if (callee.type === 'MemberExpression') {
59+
const name = utils.getStaticPropertyName(callee)
60+
if (name) {
61+
return { name, member: callee }
62+
}
63+
}
64+
return null
65+
}
66+
67+
// ------------------------------------------------------------------------------
68+
// Rule Definition
69+
// ------------------------------------------------------------------------------
70+
71+
module.exports = {
72+
meta: {
73+
type: 'suggestion',
74+
docs: {
75+
description: 'enforce custom event names always use "kebab-case"',
76+
categories: ['vue3-essential', 'essential'],
77+
url: 'https://eslint.vuejs.org/rules/custom-event-name-casing.html'
78+
},
79+
fixable: null,
80+
schema: [],
81+
messages: {
82+
unexpected: "Custom event name '{{name}}' must be kebab-case."
83+
}
84+
},
85+
86+
create(context) {
87+
const setupContexts = new Map()
88+
89+
/**
90+
* @param { Literal & { value: string } } nameLiteralNode
91+
*/
92+
function verify(nameLiteralNode) {
93+
const name = nameLiteralNode.value
94+
if (isValidEventName(name)) {
95+
return
96+
}
97+
context.report({
98+
node: nameLiteralNode,
99+
messageId: 'unexpected',
100+
data: {
101+
name
102+
}
103+
})
104+
}
105+
106+
return utils.defineTemplateBodyVisitor(
107+
context,
108+
{
109+
CallExpression(node) {
110+
const callee = node.callee
111+
const nameLiteralNode = getNameParamNode(node)
112+
if (!nameLiteralNode) {
113+
// cannot check
114+
return
115+
}
116+
if (callee.type === 'Identifier' && callee.name === '$emit') {
117+
verify(nameLiteralNode)
118+
}
119+
}
120+
},
121+
utils.compositingVisitors(
122+
utils.defineVueVisitor(context, {
123+
onSetupFunctionEnter(node, { node: vueNode }) {
124+
const contextParam = node.params[1]
125+
if (!contextParam) {
126+
// no arguments
127+
return
128+
}
129+
if (contextParam.type === 'RestElement') {
130+
// cannot check
131+
return
132+
}
133+
if (contextParam.type === 'ArrayPattern') {
134+
// cannot check
135+
return
136+
}
137+
const contextReferenceIds = new Set()
138+
const emitReferenceIds = new Set()
139+
if (contextParam.type === 'ObjectPattern') {
140+
const emitProperty = contextParam.properties.find(
141+
(p) =>
142+
p.type === 'Property' &&
143+
utils.getStaticPropertyName(p) === 'emit'
144+
)
145+
if (!emitProperty) {
146+
return
147+
}
148+
const emitParam = emitProperty.value
149+
// `setup(props, {emit})`
150+
const variable = findVariable(context.getScope(), emitParam)
151+
if (!variable) {
152+
return
153+
}
154+
for (const reference of variable.references) {
155+
emitReferenceIds.add(reference.identifier)
156+
}
157+
} else {
158+
// `setup(props, context)`
159+
const variable = findVariable(context.getScope(), contextParam)
160+
if (!variable) {
161+
return
162+
}
163+
for (const reference of variable.references) {
164+
contextReferenceIds.add(reference.identifier)
165+
}
166+
}
167+
setupContexts.set(vueNode, {
168+
contextReferenceIds,
169+
emitReferenceIds
170+
})
171+
},
172+
CallExpression(node, { node: vueNode }) {
173+
const nameLiteralNode = getNameParamNode(node)
174+
if (!nameLiteralNode) {
175+
// cannot check
176+
return
177+
}
178+
179+
// verify setup context
180+
const setupContext = setupContexts.get(vueNode)
181+
if (setupContext) {
182+
const { contextReferenceIds, emitReferenceIds } = setupContext
183+
if (emitReferenceIds.has(node.callee)) {
184+
// verify setup(props,{emit}) {emit()}
185+
verify(nameLiteralNode)
186+
} else {
187+
const emit = getCalleeMemberNode(node)
188+
if (
189+
emit &&
190+
emit.name === 'emit' &&
191+
contextReferenceIds.has(emit.member.object)
192+
) {
193+
// verify setup(props,context) {context.emit()}
194+
verify(nameLiteralNode)
195+
}
196+
}
197+
}
198+
},
199+
onVueObjectExit(node) {
200+
setupContexts.delete(node)
201+
}
202+
}),
203+
{
204+
CallExpression(node) {
205+
const nameLiteralNode = getNameParamNode(node)
206+
if (!nameLiteralNode) {
207+
// cannot check
208+
return
209+
}
210+
const emit = getCalleeMemberNode(node)
211+
// verify $emit
212+
if (emit && emit.name === '$emit') {
213+
// verify this.$emit()
214+
verify(nameLiteralNode)
215+
}
216+
}
217+
}
218+
)
219+
)
220+
}
221+
}

0 commit comments

Comments
 (0)