diff --git a/docs/rules/attribute-hyphenation.md b/docs/rules/attribute-hyphenation.md index d5fba2e31..89442fceb 100644 --- a/docs/rules/attribute-hyphenation.md +++ b/docs/rules/attribute-hyphenation.md @@ -36,7 +36,8 @@ This rule enforces using hyphenated attribute names on custom components in Vue ```json { "vue/attribute-hyphenation": ["error", "always" | "never", { - "ignore": [] + "ignore": [], + "ignoreTags": [] }] } ``` @@ -44,9 +45,10 @@ This rule enforces using hyphenated attribute names on custom components in Vue Default casing is set to `always`. By default the following attributes are ignored: `data-`, `aria-`, `slot-scope`, and all the [SVG attributes](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute) with either an upper case letter or an hyphen. -- `"always"` (default) ... Use hyphenated name. -- `"never"` ... Don't use hyphenated name except the ones that are ignored. -- `"ignore"` ... Array of ignored names +- `"always"` (default) ... Use hyphenated attribute name. +- `"never"` ... Don't use hyphenated attribute name. +- `"ignore"` ... Array of attribute names that don't need to follow the specified casing. +- `"ignoreTags"` ... Array of tag names whose attributes don't need to follow the specified casing. ### `"always"` @@ -109,6 +111,22 @@ Don't use hyphenated name but allow custom attributes +### `"never", { "ignoreTags": ["/^custom-/"] }` + + + +```vue + +``` + + + ## :couple: Related Rules - [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md) diff --git a/docs/rules/index.md b/docs/rules/index.md index e359f47ab..074f4bd46 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -270,7 +270,7 @@ For example: | [vue/prefer-prop-type-boolean-first](./prefer-prop-type-boolean-first.md) | enforce `Boolean` comes first in component prop types | :bulb: | :warning: | | [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: | :hammer: | | [vue/prefer-true-attribute-shorthand](./prefer-true-attribute-shorthand.md) | require shorthand form attribute when `v-bind` value is `true` | :bulb: | :hammer: | -| [vue/prefer-use-template-ref](./prefer-use-template-ref.md) | require using `useTemplateRef` instead of `ref` for template refs | | :hammer: | +| [vue/prefer-use-template-ref](./prefer-use-template-ref.md) | require using `useTemplateRef` instead of `ref`/`shallowRef` for template refs | | :hammer: | | [vue/require-default-export](./require-default-export.md) | require components to be the default export | | :warning: | | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | | :hammer: | | [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | :bulb: | :hammer: | @@ -281,7 +281,9 @@ For example: | [vue/require-prop-comment](./require-prop-comment.md) | require props to have a comment | | :hammer: | | [vue/require-typed-object-prop](./require-typed-object-prop.md) | enforce adding type declarations to object props | :bulb: | :hammer: | | [vue/require-typed-ref](./require-typed-ref.md) | require `ref` and `shallowRef` functions to be strongly typed | | :hammer: | +| [vue/restricted-component-names](./restricted-component-names.md) | enforce using only specific component names | | :warning: | | [vue/script-indent](./script-indent.md) | enforce consistent indentation in ` ``` - + ```vue ``` @@ -39,14 +43,15 @@ If you use v-text / v-html on a component, it will overwrite the component's con ```json { - "vue/no-v-text-v-html-on-component": [ - "error", - { "allow": ["router-link", "nuxt-link"] } - ] + "vue/no-v-text-v-html-on-component": ["error", { + "allow": ["router-link", "nuxt-link"], + "ignoreElementNamespaces": false + }] } ``` - `allow` (`string[]`) ... Specify a list of custom components for which the rule should not apply. +- `ignoreElementNamespaces` (`boolean`) ... If `true`, always treat SVG and MathML tag names as HTML elements, even if they are not used inside a SVG/MathML root element. Default is `false`. ### `{ "allow": ["router-link", "nuxt-link"] }` @@ -65,6 +70,20 @@ If you use v-text / v-html on a component, it will overwrite the component's con +### `{ "ignoreElementNamespaces": true }` + + + +```vue + +``` + + + ## :rocket: Version This rule was introduced in eslint-plugin-vue v8.4.0 diff --git a/docs/rules/prefer-use-template-ref.md b/docs/rules/prefer-use-template-ref.md index 553e99bf1..1b1b40385 100644 --- a/docs/rules/prefer-use-template-ref.md +++ b/docs/rules/prefer-use-template-ref.md @@ -2,31 +2,32 @@ pageClass: rule-details sidebarDepth: 0 title: vue/prefer-use-template-ref -description: require using `useTemplateRef` instead of `ref` for template refs +description: require using `useTemplateRef` instead of `ref`/`shallowRef` for template refs since: v9.31.0 --- # vue/prefer-use-template-ref -> require using `useTemplateRef` instead of `ref` for template refs +> require using `useTemplateRef` instead of `ref`/`shallowRef` for template refs ## :book: Rule Details Vue 3.5 introduced a new way of obtaining template refs via the [`useTemplateRef()`](https://vuejs.org/guide/essentials/template-refs.html#accessing-the-refs) API. -This rule enforces using the new `useTemplateRef` function instead of `ref` for template refs. +This rule enforces using the new `useTemplateRef` function instead of `ref`/`shallowRef` for template refs. ```vue ``` @@ -47,14 +49,16 @@ This rule skips `ref` template function refs as these should be used to allow cu ```vue ``` diff --git a/docs/rules/restricted-component-names.md b/docs/rules/restricted-component-names.md new file mode 100644 index 000000000..55e9883b8 --- /dev/null +++ b/docs/rules/restricted-component-names.md @@ -0,0 +1,69 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/restricted-component-names +description: enforce using only specific component names +since: v9.32.0 +--- + +# vue/restricted-component-names + +> enforce using only specific component names + +## :book: Rule Details + +This rule enforces consistency in component names. + + + +```vue + +``` + + + +## :wrench: Options + +```json +{ + "vue/restricted-component-names": ["error", { + "allow": [] + }] +} +``` + +### `"allow"` + + + +```vue + +``` + + + +## :couple: Related Rules + +- [vue/no-restricted-component-names](./no-restricted-component-names.md) + +## :rocket: Version + +This rule was introduced in eslint-plugin-vue v9.32.0 + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/restricted-component-names.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/restricted-component-names.js) diff --git a/docs/rules/slot-name-casing.md b/docs/rules/slot-name-casing.md new file mode 100644 index 000000000..63884fe86 --- /dev/null +++ b/docs/rules/slot-name-casing.md @@ -0,0 +1,91 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/slot-name-casing +description: enforce specific casing for slot names +since: v9.32.0 +--- + +# vue/slot-name-casing + +> enforce specific casing for slot names + +## :book: Rule Details + +This rule enforces proper casing of slot names in Vue components. + + + +```vue + +``` + + + +## :wrench: Options + +```json +{ + "vue/slot-name-casing": ["error", "camelCase" | "kebab-case" | "singleword"] +} +``` + +- `"camelCase"` (default) ... Enforce slot name to be in camel case. +- `"kebab-case"` ... Enforce slot name to be in kebab case. +- `"singleword"` ... Enforce slot name to be a single word. + +### `"kebab-case"` + + + +```vue + +``` + + + +### `"singleword"` + + + +```vue + +``` + + + +## :rocket: Version + +This rule was introduced in eslint-plugin-vue v9.32.0 + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/slot-name-casing.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/slot-name-casing.js) diff --git a/docs/rules/v-on-event-hyphenation.md b/docs/rules/v-on-event-hyphenation.md index 811b37437..493a9dac9 100644 --- a/docs/rules/v-on-event-hyphenation.md +++ b/docs/rules/v-on-event-hyphenation.md @@ -39,14 +39,16 @@ This rule enforces using hyphenated v-on event names on custom components in Vue { "vue/v-on-event-hyphenation": ["error", "always" | "never", { "autofix": false, - "ignore": [] + "ignore": [], + "ignoreTags": [] }] } ``` -- `"always"` (default) ... Use hyphenated name. -- `"never"` ... Don't use hyphenated name. -- `"ignore"` ... Array of ignored names +- `"always"` (default) ... Use hyphenated event name. +- `"never"` ... Don't use hyphenated event name. +- `"ignore"` ... Array of event names that don't need to follow the specified casing. +- `"ignoreTags"` ... Array of tag names whose events don't need to follow the specified casing. - `"autofix"` ... If `true`, enable autofix. If you are using Vue 2, we recommend that you do not use it due to its side effects. ### `"always"` @@ -104,6 +106,22 @@ Don't use hyphenated name but allow custom event names +### `"never", { "ignoreTags": ["/^custom-/"] }` + + + +```vue + +``` + + + ## :couple: Related Rules - [vue/custom-event-name-casing](./custom-event-name-casing.md) diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index ba4a7e3fd..4cf65c928 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -67,6 +67,44 @@ You can use the following configs by adding them to `eslint.config.js`. By default, all rules from **base** and **essential** categories report ESLint errors. Other rules - because they're not covering potential bugs in the application - report warnings. What does it mean? By default - nothing, but if you want - you can set up a threshold and break the build after a certain amount of warnings, instead of any. More information [here](https://eslint.org/docs/user-guide/command-line-interface#handling-warnings). ::: +#### Example configuration with [typescript-eslint](https://typescript-eslint.io/) and [Prettier](https://prettier.io/) + +```bash +npm install --save-dev eslint eslint-config-prettier eslint-plugin-vue globals typescript-eslint +``` + +```ts +import eslint from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import eslintPluginVue from 'eslint-plugin-vue'; +import globals from 'globals'; +import typescriptEslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['*.d.ts', '**/coverage', '**/dist'] }, + { + extends: [ + eslint.configs.recommended, + ...typescriptEslint.configs.recommended, + ...eslintPluginVue.configs['flat/recommended'], + ], + files: ['**/*.{ts,vue}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.browser, + parserOptions: { + parser: typescriptEslint.parser, + }, + }, + rules: { + // your rules + }, + }, + eslintConfigPrettier +); +``` + ### Configuration (`.eslintrc`) Use `.eslintrc.*` file to configure rules in ESLint < v9. See also: . diff --git a/lib/index.js b/lib/index.js index b09e247d5..ce562f458 100644 --- a/lib/index.js +++ b/lib/index.js @@ -231,11 +231,13 @@ const plugin = { 'require-typed-ref': require('./rules/require-typed-ref'), 'require-v-for-key': require('./rules/require-v-for-key'), 'require-valid-default-prop': require('./rules/require-valid-default-prop'), + 'restricted-component-names': require('./rules/restricted-component-names'), 'return-in-computed-property': require('./rules/return-in-computed-property'), 'return-in-emits-validator': require('./rules/return-in-emits-validator'), 'script-indent': require('./rules/script-indent'), 'script-setup-uses-vars': require('./rules/script-setup-uses-vars'), 'singleline-html-element-content-newline': require('./rules/singleline-html-element-content-newline'), + 'slot-name-casing': require('./rules/slot-name-casing'), 'sort-keys': require('./rules/sort-keys'), 'space-in-parens': require('./rules/space-in-parens'), 'space-infix-ops': require('./rules/space-infix-ops'), diff --git a/lib/rules/attribute-hyphenation.js b/lib/rules/attribute-hyphenation.js index 35519d231..65d096cd4 100644 --- a/lib/rules/attribute-hyphenation.js +++ b/lib/rules/attribute-hyphenation.js @@ -6,6 +6,7 @@ const utils = require('../utils') const casing = require('../utils/casing') +const { toRegExp } = require('../utils/regexp') const svgAttributes = require('../utils/svg-attributes-weird-case.json') /** @@ -56,6 +57,12 @@ module.exports = { }, uniqueItems: true, additionalItems: false + }, + ignoreTags: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + additionalItems: false } }, additionalProperties: false @@ -72,6 +79,11 @@ module.exports = { const option = context.options[0] const optionsPayload = context.options[1] const useHyphenated = option !== 'never' + /** @type {RegExp[]} */ + const ignoredTagsRegexps = ( + (optionsPayload && optionsPayload.ignoreTags) || + [] + ).map(toRegExp) const ignoredAttributes = ['data-', 'aria-', 'slot-scope', ...svgAttributes] if (optionsPayload && optionsPayload.ignore) { @@ -130,11 +142,17 @@ module.exports = { return useHyphenated ? value.toLowerCase() === value : !/-/.test(value) } + /** @param {string} name */ + function isIgnoredTagName(name) { + return ignoredTagsRegexps.some((re) => re.test(name)) + } + return utils.defineTemplateBodyVisitor(context, { VAttribute(node) { + const element = node.parent.parent if ( - !utils.isCustomComponent(node.parent.parent) && - node.parent.parent.name !== 'slot' + (!utils.isCustomComponent(element) && element.name !== 'slot') || + isIgnoredTagName(element.rawName) ) return diff --git a/lib/rules/no-duplicate-attr-inheritance.js b/lib/rules/no-duplicate-attr-inheritance.js index 654929bd1..ba20a52e5 100644 --- a/lib/rules/no-duplicate-attr-inheritance.js +++ b/lib/rules/no-duplicate-attr-inheritance.js @@ -6,6 +6,33 @@ const utils = require('../utils') +/** @param {VElement[]} elements */ +function isConditionalGroup(elements) { + if (elements.length < 2) { + return false + } + + const firstElement = elements[0] + const lastElement = elements[elements.length - 1] + const inBetweenElements = elements.slice(1, -1) + + return ( + utils.hasDirective(firstElement, 'if') && + (utils.hasDirective(lastElement, 'else-if') || + utils.hasDirective(lastElement, 'else')) && + inBetweenElements.every((element) => utils.hasDirective(element, 'else-if')) + ) +} + +/** @param {VElement[]} elements */ +function isMultiRootNodes(elements) { + if (elements.length > 1 && !isConditionalGroup(elements)) { + return true + } + + return false +} + module.exports = { meta: { type: 'suggestion', @@ -17,15 +44,30 @@ module.exports = { url: 'https://eslint.vuejs.org/rules/no-duplicate-attr-inheritance.html' }, fixable: null, - schema: [], + schema: [ + { + type: 'object', + properties: { + checkMultiRootNodes: { + type: 'boolean' + } + }, + additionalProperties: false + } + ], messages: { noDuplicateAttrInheritance: 'Set "inheritAttrs" to false.' } }, /** @param {RuleContext} context */ create(context) { + const options = context.options[0] || {} + const checkMultiRootNodes = options.checkMultiRootNodes === true + /** @type {string | number | boolean | RegExp | BigInt | null} */ let inheritsAttrs = true + /** @type {VReference[]} */ + const attrsRefs = [] /** @param {ObjectExpression} node */ function processOptions(node) { @@ -54,7 +96,7 @@ module.exports = { if (!inheritsAttrs) { return } - const attrsRef = node.references.find((reference) => { + const reference = node.references.find((reference) => { if (reference.variable != null) { // Not vm reference return false @@ -62,14 +104,32 @@ module.exports = { return reference.id.name === '$attrs' }) - if (attrsRef) { - context.report({ - node: attrsRef.id, - messageId: 'noDuplicateAttrInheritance' - }) + if (reference) { + attrsRefs.push(reference) } } - }) + }), + { + 'Program:exit'(program) { + const element = program.templateBody + if (element == null) { + return + } + + const rootElements = element.children.filter(utils.isVElement) + + if (!checkMultiRootNodes && isMultiRootNodes(rootElements)) return + + if (attrsRefs.length > 0) { + for (const attrsRef of attrsRefs) { + context.report({ + node: attrsRef.id, + messageId: 'noDuplicateAttrInheritance' + }) + } + } + } + } ) } } diff --git a/lib/rules/no-v-text-v-html-on-component.js b/lib/rules/no-v-text-v-html-on-component.js index 50ef9c76e..e3f1f5409 100644 --- a/lib/rules/no-v-text-v-html-on-component.js +++ b/lib/rules/no-v-text-v-html-on-component.js @@ -26,6 +26,9 @@ module.exports = { type: 'string' }, uniqueItems: true + }, + ignoreElementNamespaces: { + type: 'boolean' } }, additionalProperties: false @@ -41,6 +44,8 @@ module.exports = { const options = context.options[0] || {} /** @type {Set} */ const allow = new Set(options.allow) + /** @type {boolean} */ + const ignoreElementNamespaces = options.ignoreElementNamespaces === true /** * Check whether the given node is an allowed component or not. @@ -62,7 +67,10 @@ module.exports = { */ function verify(node) { const element = node.parent.parent - if (utils.isCustomComponent(element) && !isAllowedComponent(element)) { + if ( + utils.isCustomComponent(element, ignoreElementNamespaces) && + !isAllowedComponent(element) + ) { context.report({ node, loc: node.loc, diff --git a/lib/rules/prefer-use-template-ref.js b/lib/rules/prefer-use-template-ref.js index 8dcdccb38..7d01958b7 100644 --- a/lib/rules/prefer-use-template-ref.js +++ b/lib/rules/prefer-use-template-ref.js @@ -6,10 +6,42 @@ const utils = require('../utils') -/** @param expression {Expression | null} */ -function expressionIsRef(expression) { - // @ts-ignore - return expression?.callee?.name === 'ref' +/** + * @typedef ScriptRef + * @type {{node: Expression, ref: string}} + */ + +/** + * @param declarator {VariableDeclarator} + * @returns {ScriptRef} + * */ +function convertDeclaratorToScriptRef(declarator) { + return { + // @ts-ignore + node: declarator.init, + // @ts-ignore + ref: declarator.id.name + } +} + +/** + * @param body {(Statement | ModuleDeclaration)[]} + * @returns {ScriptRef[]} + * */ +function getScriptRefsFromSetupFunction(body) { + /** @type {VariableDeclaration[]} */ + const variableDeclarations = body.filter( + (child) => child.type === 'VariableDeclaration' + ) + const variableDeclarators = variableDeclarations.map( + (declaration) => declaration.declarations[0] + ) + const refDeclarators = variableDeclarators.filter((declarator) => + // @ts-ignore + ['ref', 'shallowRef'].includes(declarator.init?.callee?.name) + ) + + return refDeclarators.map(convertDeclaratorToScriptRef) } /** @type {import("eslint").Rule.RuleModule} */ @@ -18,13 +50,13 @@ module.exports = { type: 'suggestion', docs: { description: - 'require using `useTemplateRef` instead of `ref` for template refs', + 'require using `useTemplateRef` instead of `ref`/`shallowRef` for template refs', categories: undefined, url: 'https://eslint.vuejs.org/rules/prefer-use-template-ref.html' }, schema: [], messages: { - preferUseTemplateRef: "Replace 'ref' with 'useTemplateRef'." + preferUseTemplateRef: "Replace '{{name}}' with 'useTemplateRef'." } }, /** @param {RuleContext} context */ @@ -32,40 +64,33 @@ module.exports = { /** @type Set */ const templateRefs = new Set() - /** - * @typedef ScriptRef - * @type {{node: Expression, ref: string}} - */ - /** * @type ScriptRef[] */ const scriptRefs = [] return utils.compositingVisitors( - utils.defineTemplateBodyVisitor( - context, - { - 'VAttribute[directive=false]'(node) { - if (node.key.name === 'ref' && node.value?.value) { - templateRefs.add(node.value.value) - } + utils.defineTemplateBodyVisitor(context, { + 'VAttribute[directive=false]'(node) { + if (node.key.name === 'ref' && node.value?.value) { + templateRefs.add(node.value.value) } - }, - { - VariableDeclarator(declarator) { - if (!expressionIsRef(declarator.init)) { - return - } + } + }), + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node) { + // @ts-ignore + const newScriptRefs = getScriptRefsFromSetupFunction(node.body.body) - scriptRefs.push({ - // @ts-ignore - node: declarator.init, - // @ts-ignore - ref: declarator.id.name - }) - } + scriptRefs.push(...newScriptRefs) + } + }), + utils.defineScriptSetupVisitor(context, { + Program(node) { + const newScriptRefs = getScriptRefsFromSetupFunction(node.body) + + scriptRefs.push(...newScriptRefs) } - ), + }), { 'Program:exit'() { for (const templateRef of templateRefs) { @@ -79,7 +104,11 @@ module.exports = { context.report({ node: scriptRef.node, - messageId: 'preferUseTemplateRef' + messageId: 'preferUseTemplateRef', + data: { + // @ts-ignore + name: scriptRef.node?.callee?.name + } }) } } diff --git a/lib/rules/require-explicit-slots.js b/lib/rules/require-explicit-slots.js index f87503bb7..5298e598c 100644 --- a/lib/rules/require-explicit-slots.js +++ b/lib/rules/require-explicit-slots.js @@ -98,30 +98,22 @@ module.exports = { return utils.compositingVisitors( utils.defineScriptSetupVisitor(context, { - onDefineSlotsEnter(node) { - const typeArguments = - 'typeArguments' in node ? 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) { - const slotName = getSlotsName(memberNode) - if (!slotName) continue - - if (slotsDefined.has(slotName)) { - context.report({ - node: memberNode, - messageId: 'alreadyDefinedSlot', - data: { - slotName - } - }) - } else { - slotsDefined.add(slotName) - } + onDefineSlotsEnter(_node, slots) { + for (const slot of slots) { + if (!slot.slotName) { + continue + } + + if (slotsDefined.has(slot.slotName)) { + context.report({ + node: slot.node, + messageId: 'alreadyDefinedSlot', + data: { + slotName: slot.slotName + } + }) + } else { + slotsDefined.add(slot.slotName) } } } diff --git a/lib/rules/restricted-component-names.js b/lib/rules/restricted-component-names.js new file mode 100644 index 000000000..636224db6 --- /dev/null +++ b/lib/rules/restricted-component-names.js @@ -0,0 +1,80 @@ +/** + * @author Wayne Zhang + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') +const { toRegExp } = require('../utils/regexp') + +const htmlElements = require('../utils/html-elements.json') +const deprecatedHtmlElements = require('../utils/deprecated-html-elements.json') +const svgElements = require('../utils/svg-elements.json') +const vue2builtinComponents = require('../utils/vue2-builtin-components') +const vue3builtinComponents = require('../utils/vue3-builtin-components') + +const reservedNames = new Set([ + ...htmlElements, + ...deprecatedHtmlElements, + ...svgElements, + ...vue2builtinComponents, + ...vue3builtinComponents +]) + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'enforce using only specific component names', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/restricted-component-names.html' + }, + fixable: null, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + allow: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + additionalItems: false + } + } + } + ], + messages: { + invalidName: 'Component name "{{name}}" is not allowed.' + } + }, + /** @param {RuleContext} context */ + create(context) { + const options = context.options[0] || {} + /** @type {RegExp[]} */ + const allow = (options.allow || []).map(toRegExp) + + /** @param {string} name */ + function isAllowedTarget(name) { + return reservedNames.has(name) || allow.some((re) => re.test(name)) + } + + return utils.defineTemplateBodyVisitor(context, { + VElement(node) { + const name = node.rawName + if (isAllowedTarget(name)) { + return + } + + context.report({ + node, + loc: node.loc, + messageId: 'invalidName', + data: { + name + } + }) + } + }) + } +} diff --git a/lib/rules/slot-name-casing.js b/lib/rules/slot-name-casing.js new file mode 100644 index 000000000..6d98d8d82 --- /dev/null +++ b/lib/rules/slot-name-casing.js @@ -0,0 +1,82 @@ +/** + * @author Wayne Zhang + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') +const casing = require('../utils/casing') + +/** + * @typedef { 'camelCase' | 'kebab-case' | 'singleword' } OptionType + * @typedef { (str: string) => boolean } CheckerType + */ + +/** + * Checks whether the given string is a single word. + * @param {string} str + * @return {boolean} + */ +function isSingleWord(str) { + return /^[a-z]+$/u.test(str) +} + +/** @type {OptionType[]} */ +const allowedCaseOptions = ['camelCase', 'kebab-case', 'singleword'] + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'enforce specific casing for slot names', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/slot-name-casing.html' + }, + fixable: null, + schema: [ + { + enum: allowedCaseOptions + } + ], + messages: { + invalidCase: 'Slot name "{{name}}" is not {{caseType}}.' + } + }, + /** @param {RuleContext} context */ + create(context) { + const option = context.options[0] + + /** @type {OptionType} */ + const caseType = allowedCaseOptions.includes(option) ? option : 'camelCase' + + /** @type {CheckerType} */ + const checker = + caseType === 'singleword' ? isSingleWord : casing.getChecker(caseType) + + /** @param {VAttribute} node */ + function processSlotNode(node) { + const name = node.value?.value + if (name && !checker(name)) { + context.report({ + node, + loc: node.loc, + messageId: 'invalidCase', + data: { + name, + caseType + } + }) + } + } + + return utils.defineTemplateBodyVisitor(context, { + /** @param {VElement} node */ + "VElement[name='slot']"(node) { + const slotName = utils.getAttribute(node, 'name') + if (slotName) { + processSlotNode(slotName) + } + } + }) + } +} diff --git a/lib/rules/v-on-event-hyphenation.js b/lib/rules/v-on-event-hyphenation.js index f99a45fdc..c9fac76e8 100644 --- a/lib/rules/v-on-event-hyphenation.js +++ b/lib/rules/v-on-event-hyphenation.js @@ -2,6 +2,7 @@ const utils = require('../utils') const casing = require('../utils/casing') +const { toRegExp } = require('../utils/regexp') module.exports = { meta: { @@ -35,6 +36,12 @@ module.exports = { }, uniqueItems: true, additionalItems: false + }, + ignoreTags: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + additionalItems: false } }, additionalProperties: false @@ -56,6 +63,11 @@ module.exports = { const useHyphenated = option !== 'never' /** @type {string[]} */ const ignoredAttributes = (optionsPayload && optionsPayload.ignore) || [] + /** @type {RegExp[]} */ + const ignoredTagsRegexps = ( + (optionsPayload && optionsPayload.ignoreTags) || + [] + ).map(toRegExp) const autofix = Boolean(optionsPayload && optionsPayload.autofix) const caseConverter = casing.getConverter( @@ -99,9 +111,20 @@ module.exports = { return useHyphenated ? value.toLowerCase() === value : !/-/.test(value) } + /** @param {string} name */ + function isIgnoredTagName(name) { + return ignoredTagsRegexps.some((re) => re.test(name)) + } + return utils.defineTemplateBodyVisitor(context, { "VAttribute[directive=true][key.name.name='on']"(node) { - if (!utils.isCustomComponent(node.parent.parent)) return + const element = node.parent.parent + if ( + !utils.isCustomComponent(element) || + isIgnoredTagName(element.rawName) + ) { + return + } if (!node.key.argument || node.key.argument.type !== 'VIdentifier') { return } diff --git a/lib/utils/index.js b/lib/utils/index.js index 58cd32689..167edf208 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -26,6 +26,10 @@ const { getScope } = require('./scope') * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownEmit} ComponentUnknownEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentEmit} ComponentEmit + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeSlot} ComponentTypeSlot + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeSlot} ComponentInferTypeSlot + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownSlot} ComponentUnknownSlot + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentSlot} ComponentSlot * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentModelName} ComponentModelName * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentModel} ComponentModel */ @@ -70,6 +74,7 @@ const { const { getComponentPropsFromTypeDefine, getComponentEmitsFromTypeDefine, + getComponentSlotsFromTypeDefine, isTypeNode } = require('./ts-utils') @@ -941,19 +946,30 @@ module.exports = { /** * Check whether the given node is a custom component or not. * @param {VElement} node The start tag node to check. + * @param {boolean} [ignoreElementNamespaces=false] If `true`, ignore element namespaces. * @returns {boolean} `true` if the node is a custom component. */ - isCustomComponent(node) { - return ( - (this.isHtmlElementNode(node) && - !this.isHtmlWellKnownElementName(node.rawName)) || - (this.isSvgElementNode(node) && - !this.isSvgWellKnownElementName(node.rawName)) || - (this.isMathElementNode(node) && - !this.isMathWellKnownElementName(node.rawName)) || + isCustomComponent(node, ignoreElementNamespaces = false) { + if ( hasAttribute(node, 'is') || hasDirective(node, 'bind', 'is') || hasDirective(node, 'is') + ) { + return true + } + + const isHtmlName = this.isHtmlWellKnownElementName(node.rawName) + const isSvgName = this.isSvgWellKnownElementName(node.rawName) + const isMathName = this.isMathWellKnownElementName(node.rawName) + + if (ignoreElementNamespaces) { + return !isHtmlName && !isSvgName && !isMathName + } + + return ( + (this.isHtmlElementNode(node) && !isHtmlName) || + (this.isSvgElementNode(node) && !isSvgName) || + (this.isMathElementNode(node) && !isMathName) ) }, @@ -1424,7 +1440,7 @@ module.exports = { 'onDefineSlotsEnter', 'onDefineSlotsExit', (candidateMacro, node) => candidateMacro === node, - () => undefined + getComponentSlotsFromDefineSlots ), new MacroListener( 'defineExpose', @@ -3361,6 +3377,28 @@ function getComponentEmitsFromDefineEmits(context, node) { } ] } + +/** + * Get all slots from `defineSlots` call expression. + * @param {RuleContext} context The rule context object. + * @param {CallExpression} node `defineSlots` call expression + * @return {ComponentSlot[]} Array of component slots + */ +function getComponentSlotsFromDefineSlots(context, node) { + const typeArguments = + 'typeArguments' in node ? node.typeArguments : node.typeParameters + if (typeArguments && typeArguments.params.length > 0) { + return getComponentSlotsFromTypeDefine(context, typeArguments.params[0]) + } + return [ + { + type: 'unknown', + slotName: null, + node: null + } + ] +} + /** * Get model info from `defineModel` call expression. * @param {RuleContext} _context The rule context object. @@ -3403,6 +3441,7 @@ function getComponentModelFromDefineModel(_context, node) { typeNode: null } } + /** * Get all props by looking at all component's properties * @param {ObjectExpression|ArrayExpression} propsNode Object with props definition diff --git a/lib/utils/ts-utils/index.js b/lib/utils/ts-utils/index.js index 8b6c53b26..3db610d1c 100644 --- a/lib/utils/ts-utils/index.js +++ b/lib/utils/ts-utils/index.js @@ -5,11 +5,13 @@ const { isTSTypeLiteralOrTSFunctionType, extractRuntimeEmits, flattenTypeNodes, - isTSInterfaceBody + isTSInterfaceBody, + extractRuntimeSlots } = require('./ts-ast') const { getComponentPropsFromTypeDefineTypes, - getComponentEmitsFromTypeDefineTypes + getComponentEmitsFromTypeDefineTypes, + getComponentSlotsFromTypeDefineTypes } = require('./ts-types') /** @@ -22,12 +24,16 @@ const { * @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit * @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + * @typedef {import('../index').ComponentTypeSlot} ComponentTypeSlot + * @typedef {import('../index').ComponentInferTypeSlot} ComponentInferTypeSlot + * @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot */ module.exports = { isTypeNode, getComponentPropsFromTypeDefine, - getComponentEmitsFromTypeDefine + getComponentEmitsFromTypeDefine, + getComponentSlotsFromTypeDefine } /** @@ -86,3 +92,30 @@ function getComponentEmitsFromTypeDefine(context, emitsNode) { } return result } + +/** + * Get all slots by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} slotsNode Type with slots definition + * @return {(ComponentTypeSlot|ComponentInferTypeSlot|ComponentUnknownSlot)[]} Array of component slots + */ +function getComponentSlotsFromTypeDefine(context, slotsNode) { + /** @type {(ComponentTypeSlot|ComponentInferTypeSlot|ComponentUnknownSlot)[]} */ + const result = [] + for (const defNode of flattenTypeNodes( + context, + /** @type {TSESTreeTypeNode} */ (slotsNode) + )) { + if (isTSInterfaceBody(defNode) || isTSTypeLiteral(defNode)) { + result.push(...extractRuntimeSlots(defNode)) + } else { + result.push( + ...getComponentSlotsFromTypeDefineTypes( + context, + /** @type {TypeNode} */ (defNode) + ) + ) + } + } + return result +} diff --git a/lib/utils/ts-utils/ts-ast.js b/lib/utils/ts-utils/ts-ast.js index ddbb9de05..1021b4baf 100644 --- a/lib/utils/ts-utils/ts-ast.js +++ b/lib/utils/ts-utils/ts-ast.js @@ -15,6 +15,8 @@ const { inferRuntimeTypeFromTypeNode } = require('./ts-types') * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp * @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + * @typedef {import('../index').ComponentTypeSlot} ComponentTypeSlot + * @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot */ const noop = Function.prototype @@ -26,7 +28,8 @@ module.exports = { isTSTypeLiteral, isTSTypeLiteralOrTSFunctionType, extractRuntimeProps, - extractRuntimeEmits + extractRuntimeEmits, + extractRuntimeSlots } /** @@ -209,6 +212,38 @@ function* extractRuntimeEmits(node) { } } +/** + * @param {TSESTreeTSTypeLiteral | TSESTreeTSInterfaceBody} node + * @returns {IterableIterator} + */ +function* extractRuntimeSlots(node) { + const members = node.type === 'TSTypeLiteral' ? node.members : node.body + for (const member of members) { + if ( + member.type === 'TSPropertySignature' || + member.type === 'TSMethodSignature' + ) { + if (member.key.type !== 'Identifier' && member.key.type !== 'Literal') { + yield { + type: 'unknown', + slotName: null, + node: /** @type {Expression} */ (member.key) + } + continue + } + yield { + type: 'type', + key: /** @type {Identifier | Literal} */ (member.key), + slotName: + member.key.type === 'Identifier' + ? member.key.name + : `${member.key.value}`, + node: /** @type {TSPropertySignature | TSMethodSignature} */ (member) + } + } + } +} + /** * @param {TSESTreeParameter} eventName * @param {TSCallSignatureDeclaration | TSFunctionType} member diff --git a/lib/utils/ts-utils/ts-types.js b/lib/utils/ts-utils/ts-types.js index abb303862..2fe354c2c 100644 --- a/lib/utils/ts-utils/ts-types.js +++ b/lib/utils/ts-utils/ts-types.js @@ -24,11 +24,14 @@ const { * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp * @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + * @typedef {import('../index').ComponentInferTypeSlot} ComponentInferTypeSlot + * @typedef {import('../index').ComponentUnknownSlot} ComponentUnknownSlot */ module.exports = { getComponentPropsFromTypeDefineTypes, getComponentEmitsFromTypeDefineTypes, + getComponentSlotsFromTypeDefineTypes, inferRuntimeTypeFromTypeNode } @@ -122,6 +125,34 @@ function getComponentEmitsFromTypeDefineTypes(context, emitsNode) { return [...extractRuntimeEmits(type, tsNode, emitsNode, services)] } +/** + * Get all slots by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} slotsNode Type with slots definition + * @return {(ComponentInferTypeSlot|ComponentUnknownSlot)[]} Array of component slots + */ +function getComponentSlotsFromTypeDefineTypes(context, slotsNode) { + const services = getTSParserServices(context) + const tsNode = services && services.tsNodeMap.get(slotsNode) + const type = tsNode && services.checker.getTypeAtLocation(tsNode) + if ( + !type || + isAny(type) || + isUnknown(type) || + isNever(type) || + isNull(type) + ) { + return [ + { + type: 'unknown', + slotName: null, + node: slotsNode + } + ] + } + return [...extractRuntimeSlots(type, slotsNode)] +} + /** * @param {RuleContext} context The ESLint rule context object. * @param {TypeNode|Expression} node @@ -259,6 +290,23 @@ function* extractRuntimeEmits(type, tsNode, emitsNode, services) { } } +/** + * @param {Type} type + * @param {TypeNode} slotsNode Type with slots definition + * @returns {IterableIterator} + */ +function* extractRuntimeSlots(type, slotsNode) { + for (const property of type.getProperties()) { + const name = property.getName() + + yield { + type: 'infer-type', + slotName: name, + node: slotsNode + } + } +} + /** * @param {Type} type * @returns {Iterable} diff --git a/package.json b/package.json index 75d83695f..18d136ea5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-vue", - "version": "9.31.0", + "version": "9.32.0", "description": "Official ESLint plugin for Vue.js", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -67,7 +67,7 @@ }, "devDependencies": { "@ota-meshi/site-kit-eslint-editor-vue": "^0.2.4", - "@stylistic/eslint-plugin": "^2.9.0", + "@stylistic/eslint-plugin": "~2.10.0", "@types/eslint": "^8.56.2", "@types/eslint-visitor-keys": "^3.3.2", "@types/natural-compare": "^1.4.3", diff --git a/tests/lib/rules/attribute-hyphenation.js b/tests/lib/rules/attribute-hyphenation.js index 18d60e19c..738d59ae9 100644 --- a/tests/lib/rules/attribute-hyphenation.js +++ b/tests/lib/rules/attribute-hyphenation.js @@ -85,6 +85,26 @@ ruleTester.run('attribute-hyphenation', rule, { filename: 'test.vue', code: '', options: ['never'] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['never', { ignoreTags: ['VueComponent', '/^custom-/'] }] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['always', { ignoreTags: ['VueComponent', '/^custom-/'] }] } ], @@ -450,6 +470,52 @@ ruleTester.run('attribute-hyphenation', rule, { line: 1 } ] + }, + { + code: ` + + `, + output: ` + + `, + options: ['never', { ignoreTags: ['CustomComponent'] }], + errors: [ + { + message: "Attribute 'my-prop' can't be hyphenated.", + type: 'VIdentifier', + line: 3, + column: 17 + } + ] + }, + { + code: ` + + `, + output: ` + + `, + options: ['always', { ignoreTags: ['CustomComponent'] }], + errors: [ + { + message: "Attribute 'myProp' must be hyphenated.", + type: 'VIdentifier', + line: 3, + column: 17 + } + ] } ] }) diff --git a/tests/lib/rules/no-duplicate-attr-inheritance.js b/tests/lib/rules/no-duplicate-attr-inheritance.js index e38711a54..41e9f1522 100644 --- a/tests/lib/rules/no-duplicate-attr-inheritance.js +++ b/tests/lib/rules/no-duplicate-attr-inheritance.js @@ -43,6 +43,57 @@ ruleTester.run('no-duplicate-attr-inheritance', rule, { ` }, + // ignore multi root by default + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ checkMultiRootNodes: false }] + }, { filename: 'test.vue', code: ` @@ -151,6 +202,67 @@ ruleTester.run('no-duplicate-attr-inheritance', rule, { line: 5 } ] + }, + { + filename: 'test.vue', + code: ``, + options: [{ checkMultiRootNodes: true }], + errors: [{ message: 'Set "inheritAttrs" to false.' }] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ checkMultiRootNodes: true }], + errors: [{ message: 'Set "inheritAttrs" to false.' }] + }, + // condition group as a single root node + { + filename: 'test.vue', + code: ` + + `, + errors: [{ message: 'Set "inheritAttrs" to false.' }] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [{ message: 'Set "inheritAttrs" to false.' }] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [{ message: 'Set "inheritAttrs" to false.' }] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [{ message: 'Set "inheritAttrs" to false.' }] } ] }) diff --git a/tests/lib/rules/no-v-text-v-html-on-component.js b/tests/lib/rules/no-v-text-v-html-on-component.js index ebf2901ba..bb403489e 100644 --- a/tests/lib/rules/no-v-text-v-html-on-component.js +++ b/tests/lib/rules/no-v-text-v-html-on-component.js @@ -59,6 +59,26 @@ tester.run('no-v-text-v-html-on-component', rule, { `, options: [{ allow: ['RouterLink', 'nuxt-link'] }] + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ ignoreElementNamespaces: true }] } ], invalid: [ @@ -167,6 +187,28 @@ tester.run('no-v-text-v-html-on-component', rule, { column: 22 } ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ ignoreElementNamespaces: false }], + errors: [ + { + message: "Using v-text on component may break component's content.", + line: 3, + column: 12 + }, + { + message: "Using v-text on component may break component's content.", + line: 4, + column: 13 + } + ] } ] }) diff --git a/tests/lib/rules/prefer-use-template-ref.js b/tests/lib/rules/prefer-use-template-ref.js index 49a2f0759..77020cdcf 100644 --- a/tests/lib/rules/prefer-use-template-ref.js +++ b/tests/lib/rules/prefer-use-template-ref.js @@ -197,6 +197,61 @@ tester.run('prefer-use-template-ref', rule, { const button = ref(); ` + }, + { + filename: 'ref-in-block.vue', + code: ` + + + ` + }, + { + filename: 'ref-in-block-setup-fn.vue', + code: ` + + + ` } ], invalid: [ @@ -214,6 +269,9 @@ tester.run('prefer-use-template-ref', rule, { errors: [ { messageId: 'preferUseTemplateRef', + data: { + name: 'ref' + }, line: 7, column: 22 } @@ -235,6 +293,9 @@ tester.run('prefer-use-template-ref', rule, { errors: [ { messageId: 'preferUseTemplateRef', + data: { + name: 'ref' + }, line: 9, column: 22 } @@ -256,43 +317,22 @@ tester.run('prefer-use-template-ref', rule, { errors: [ { messageId: 'preferUseTemplateRef', + data: { + name: 'ref' + }, line: 8, column: 25 }, { messageId: 'preferUseTemplateRef', + data: { + name: 'ref' + }, line: 9, column: 22 } ] }, - { - filename: 'ref-in-block.vue', - code: ` - - - `, - errors: [ - { - messageId: 'preferUseTemplateRef', - line: 14, - column: 33 - } - ] - }, { filename: 'setup-function-only-refs.vue', code: ` @@ -314,10 +354,35 @@ tester.run('prefer-use-template-ref', rule, { errors: [ { messageId: 'preferUseTemplateRef', + data: { + name: 'ref' + }, line: 12, column: 28 } ] + }, + { + filename: 'single-shallowRef.vue', + code: ` + + + `, + errors: [ + { + messageId: 'preferUseTemplateRef', + data: { + name: 'shallowRef' + }, + line: 7, + column: 22 + } + ] } ] }) diff --git a/tests/lib/rules/require-explicit-slots.js b/tests/lib/rules/require-explicit-slots.js index 92d1a1334..f99614119 100644 --- a/tests/lib/rules/require-explicit-slots.js +++ b/tests/lib/rules/require-explicit-slots.js @@ -34,6 +34,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -48,6 +78,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -62,6 +122,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -76,6 +166,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -90,6 +210,36 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -178,6 +328,40 @@ tester.run('require-explicit-slots', rule, { }>() ` }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, { filename: 'test.vue', code: ` @@ -191,6 +375,36 @@ tester.run('require-explicit-slots', rule, { default(props: { msg: string }): any }>() ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` } ], invalid: [ @@ -261,6 +475,46 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -280,6 +534,46 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -299,6 +593,46 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -342,6 +676,48 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + // ignore attribute binding except string literal + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, + { + // ignore attribute binding except string literal + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slots must be explicitly defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -362,6 +738,48 @@ tester.run('require-explicit-slots', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] + }, { filename: 'test.vue', code: ` @@ -384,6 +802,56 @@ tester.run('require-explicit-slots', rule, { message: 'Slot foo is already defined.' } ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: 'Slot foo is already defined.' + } + ] } ] }) diff --git a/tests/lib/rules/restricted-component-names.js b/tests/lib/rules/restricted-component-names.js new file mode 100644 index 000000000..db87ca804 --- /dev/null +++ b/tests/lib/rules/restricted-component-names.js @@ -0,0 +1,78 @@ +/** + * @author Wayne Zhang + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('../../eslint-compat').RuleTester +const rule = require('../../../lib/rules/restricted-component-names') + +const tester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('restricted-component-names', rule, { + valid: [ + '', + '', + { + filename: 'test.vue', + code: ` + + `, + options: [{ allow: ['/^foo-/', '/-bar$/'] }] + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'invalidName', + data: { name: 'Button' }, + line: 3 + }, + { + messageId: 'invalidName', + data: { name: 'foo-button' }, + line: 4 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ allow: ['/^foo-/', 'bar'] }], + errors: [ + { + messageId: 'invalidName', + data: { name: 'bar-button' }, + line: 3 + }, + { + messageId: 'invalidName', + data: { name: 'foo' }, + line: 4 + } + ] + } + ] +}) diff --git a/tests/lib/rules/slot-name-casing.js b/tests/lib/rules/slot-name-casing.js new file mode 100644 index 000000000..ea8b72aab --- /dev/null +++ b/tests/lib/rules/slot-name-casing.js @@ -0,0 +1,148 @@ +/** + * @author WayneZhang + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('../../eslint-compat').RuleTester +const rule = require('../../../lib/rules/slot-name-casing') + +const tester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('slot-name-casing', rule, { + valid: [ + ``, + ``, + ``, + ``, + ``, + { + filename: 'test.vue', + code: ` + + `, + options: ['kebab-case'] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['singleword'] + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'invalidCase', + data: { + name: 'foo-bar', + caseType: 'camelCase' + }, + line: 3, + column: 17 + }, + { + messageId: 'invalidCase', + data: { + name: 'foo-Bar_baz', + caseType: 'camelCase' + }, + line: 4, + column: 17 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['kebab-case'], + errors: [ + { + messageId: 'invalidCase', + data: { + name: 'fooBar', + caseType: 'kebab-case' + }, + line: 3, + column: 17 + }, + { + messageId: 'invalidCase', + data: { + name: 'foo-Bar_baz', + caseType: 'kebab-case' + }, + line: 4, + column: 17 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['singleword'], + errors: [ + { + messageId: 'invalidCase', + data: { + name: 'foo-bar', + caseType: 'singleword' + }, + line: 3, + column: 17 + }, + { + messageId: 'invalidCase', + data: { + name: 'fooBar', + caseType: 'singleword' + }, + line: 4, + column: 17 + }, + { + messageId: 'invalidCase', + data: { + name: 'foo-Bar_baz', + caseType: 'singleword' + }, + line: 5, + column: 17 + } + ] + } + ] +}) diff --git a/tests/lib/rules/v-on-event-hyphenation.js b/tests/lib/rules/v-on-event-hyphenation.js index 54d2ec435..3f58ce1f0 100644 --- a/tests/lib/rules/v-on-event-hyphenation.js +++ b/tests/lib/rules/v-on-event-hyphenation.js @@ -44,6 +44,32 @@ tester.run('v-on-event-hyphenation', rule, { `, options: ['never', { ignore: ['custom'] }] + }, + { + code: ` + + `, + options: ['never', { ignore: ['custom-event'] }] + }, + { + code: ` + + `, + options: ['never', { ignoreTags: ['/^Vue/', 'custom-component'] }] + }, + { + code: ` + + `, + options: ['always', { ignoreTags: ['/^Vue/', 'custom-component'] }] } ], invalid: [ @@ -179,6 +205,50 @@ tester.run('v-on-event-hyphenation', rule, { "v-on event '@upDate:model-value' can't be hyphenated.", "v-on event '@up-date:model-value' can't be hyphenated." ] + }, + { + code: ` + + `, + output: ` + + `, + options: ['never', { autofix: true, ignoreTags: ['CustomComponent'] }], + errors: [ + { + message: "v-on event 'v-on:custom-event' can't be hyphenated.", + line: 3, + column: 23 + } + ] + }, + { + code: ` + + `, + output: ` + + `, + options: ['always', { autofix: true, ignoreTags: ['CustomComponent'] }], + errors: [ + { + message: "v-on event 'v-on:customEvent' must be hyphenated.", + line: 3, + column: 23 + } + ] } ] }) diff --git a/tests/lib/utils/ts-utils/index/get-component-slots.js b/tests/lib/utils/ts-utils/index/get-component-slots.js new file mode 100644 index 000000000..410021b93 --- /dev/null +++ b/tests/lib/utils/ts-utils/index/get-component-slots.js @@ -0,0 +1,115 @@ +/** + * Test for getComponentSlotsFromTypeDefineTypes + */ +'use strict' + +const path = require('path') +const fs = require('fs') +const Linter = require('../../../../eslint-compat').Linter +const parser = require('vue-eslint-parser') +const tsParser = require('@typescript-eslint/parser') +const utils = require('../../../../../lib/utils/index') +const assert = require('assert') + +const FIXTURES_ROOT = path.resolve( + __dirname, + '../../../../fixtures/utils/ts-utils' +) +const TSCONFIG_PATH = path.resolve(FIXTURES_ROOT, './tsconfig.json') +const SRC_TS_TEST_PATH = path.join(FIXTURES_ROOT, './src/test.ts') + +function extractComponentSlots(code, tsFileCode) { + const linter = new Linter() + const result = [] + const config = { + files: ['**/*.vue'], + languageOptions: { + parser, + ecmaVersion: 2020, + parserOptions: { + parser: tsParser, + project: [TSCONFIG_PATH], + extraFileExtensions: ['.vue'] + } + }, + plugins: { + test: { + rules: { + test: { + create(context) { + return utils.defineScriptSetupVisitor(context, { + onDefineSlotsEnter(_node, slots) { + result.push( + ...slots.map((prop) => ({ + type: prop.type, + name: prop.slotName + })) + ) + } + }) + } + } + } + } + }, + rules: { + 'test/test': 'error' + } + } + fs.writeFileSync(SRC_TS_TEST_PATH, tsFileCode || '', 'utf8') + // clean './src/test.ts' cache + tsParser.clearCaches() + assert.deepStrictEqual( + linter.verify(code, config, path.join(FIXTURES_ROOT, './src/test.vue')), + [] + ) + // reset + fs.writeFileSync(SRC_TS_TEST_PATH, '', 'utf8') + return result +} + +describe('getComponentSlotsFromTypeDefineTypes', () => { + for (const { scriptCode, tsFileCode, slots: expected } of [ + { + scriptCode: ` + defineSlots<{ + default(props: { msg: string }): any + }>() + `, + slots: [{ type: 'type', name: 'default' }] + }, + { + scriptCode: ` + interface Slots { + default(props: { msg: string }): any + } + defineSlots() + `, + slots: [{ type: 'type', name: 'default' }] + }, + { + scriptCode: ` + type Slots = { + default(props: { msg: string }): any + } + defineSlots() + `, + slots: [{ type: 'type', name: 'default' }] + } + ]) { + const code = ` + + ` + it(`should return expected slots with :${code}`, () => { + const slots = extractComponentSlots(code, tsFileCode) + + assert.deepStrictEqual( + slots, + expected, + `\n${JSON.stringify(slots)}\n === \n${JSON.stringify(expected)}` + ) + }) + } +}) diff --git a/typings/eslint-plugin-vue/util-types/utils.ts b/typings/eslint-plugin-vue/util-types/utils.ts index 3e9184262..ebe9933d3 100644 --- a/typings/eslint-plugin-vue/util-types/utils.ts +++ b/typings/eslint-plugin-vue/util-types/utils.ts @@ -42,8 +42,8 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase { onDefineEmitsExit?(node: CallExpression, emits: ComponentEmit[]): void onDefineOptionsEnter?(node: CallExpression): void onDefineOptionsExit?(node: CallExpression): void - onDefineSlotsEnter?(node: CallExpression): void - onDefineSlotsExit?(node: CallExpression): void + onDefineSlotsEnter?(node: CallExpression, slots: ComponentSlot[]): void + onDefineSlotsExit?(node: CallExpression, slots: ComponentSlot[]): void onDefineExposeEnter?(node: CallExpression): void onDefineExposeExit?(node: CallExpression): void onDefineModelEnter?(node: CallExpression, model: ComponentModel): void @@ -52,6 +52,7 @@ export interface ScriptSetupVisitor extends ScriptSetupVisitorBase { | ((node: VAST.ParamNode) => void) | ((node: CallExpression, props: ComponentProp[]) => void) | ((node: CallExpression, emits: ComponentEmit[]) => void) + | ((node: CallExpression, slots: ComponentSlot[]) => void) | ((node: CallExpression, model: ComponentModel) => void) | undefined } @@ -191,6 +192,30 @@ export type ComponentEmit = | ComponentInferTypeEmit | ComponentUnknownEmit +export type ComponentUnknownSlot = { + type: 'unknown' + slotName: null + node: Expression | SpreadElement | TypeNode | null +} + +export type ComponentTypeSlot = { + type: 'type' + key: Identifier | Literal + slotName: string + node: TSPropertySignature | TSMethodSignature +} + +export type ComponentInferTypeSlot = { + type: 'infer-type' + slotName: string + node: TypeNode +} + +export type ComponentSlot = + | ComponentTypeSlot + | ComponentInferTypeSlot + | ComponentUnknownSlot + export type ComponentModelName = { modelName: string node: Literal | null