Repository: vuejs/eslint-plugin-vue
Pull request #2325 (2023-11-25 06:30:07)

feat: implement require-explicit-slots
This is an attempt at solving https://github.com/vuejs/eslint-plugin-vue/issues/2294

I would like to hear your opinion on the handling of slots declared twice. As you can see from the implementation, I'm reporting this as well but I'm not sure if such a thing should be responsibility of this rule.

What do you think?

Also, I did not implement a fixer for now as we do not know how the slot function is typed. I think this is good enough for now but any opinions?

Thank you.



mussinbenarbia: Looks like the tests are failing 🤔 They all pass locally, let me see if there is some version mismatch somewhere.
FloEdelmann: We plan to drop support for Node v14 and v16 in eslint-plugin-vue v10 (see #2166), so new rules intended to be merged before that major version should be compatible with Node v14 as well. But we can also mark this PR as a breaking change and only release it with or after v10.
mussinbenarbia: @FloEdelmann I think this makes sense to introduce in v10 then since consumers of it wouldn't be on node 14 anyway due to the reasons mentioned above 🙏 
mussinbenarbia: @ota-meshi @FloEdelmann I made some changes to the implementation based on the comments. Could you please take another look when you get the chance? 🙏 
mussinbenarbia: @ota-meshi I addressed the comments. I'm not sure if I understood the comment about the docs correctly. I'll make the change if it's still wrong

Patch:
diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md
index f7ce60dd..90cb03ed 100644
--- a/docs/rules/require-explicit-emits.md
+++ b/docs/rules/require-explicit-emits.md
@@ -112,6 +112,7 @@ export default {
 ## :couple: Related Rules
 
 - [vue/no-unused-emit-declarations](./no-unused-emit-declarations.md)
+- [vue/require-explicit-slots](./require-explicit-slots.md)
 
 ## :books: Further Reading
 
diff --git a/docs/rules/require-explicit-slots.md b/docs/rules/require-explicit-slots.md
new file mode 100644
index 00000000..ac305089
--- /dev/null
+++ b/docs/rules/require-explicit-slots.md
@@ -0,0 +1,68 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/require-explicit-slots
+description: require slots to be explicitly defined with defineSlots
+---
+
+# vue/require-explicit-slots
+
+> require slots to be explicitly defined
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule enforces all slots used in the template to be defined once either in the `script setup` block with the [`defineSlots`](https://vuejs.org/api/sfc-script-setup.html) macro, or with the [`slots property`](https://vuejs.org/api/options-rendering.html#slots) in the Options API.
+
+<eslint-code-block :rules="{'vue/require-explicit-slots': ['error']}">
+
+```vue
+<template>
+  <div>
+    <!-- ✓ GOOD -->
+    <slot />
+    <slot name="foo" />
+    <!-- ✗ BAD -->
+    <slot name="bar" />
+  </div>
+</template>
+<script setup lang="ts">
+defineSlots<{
+  default(props: { msg: string }): any
+  foo(props: { msg: string }): any
+}>()
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/require-explicit-slots': ['error']}">
+
+```vue
+<template>
+  <div>
+    <!-- ✓ GOOD -->
+    <slot />
+    <slot name="foo" />
+    <!-- ✗ BAD -->
+    <slot name="bar" />
+  </div>
+</template>
+<script lang="ts">
+import { SlotsType } from 'vue'
+
+defineComponent({
+  slots: Object as SlotsType<{
+    default: { msg: string }
+    foo: { msg: string }
+  }>
+})
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
diff --git a/lib/rules/require-explicit-slots.js b/lib/rules/require-explicit-slots.js
new file mode 100644
index 00000000..ed2d2edb
--- /dev/null
+++ b/lib/rules/require-explicit-slots.js
@@ -0,0 +1,128 @@
+/**
+ * @author Mussin Benarbia
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/**
+ * @typedef {import('@typescript-eslint/types').TSESTree.TypeNode} TypeNode
+ */
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'require slots to be explicitly defined',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/require-explicit-slots.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      requireExplicitSlots: 'Slots must be explicitly defined.',
+      alreadyDefinedSlot: 'Slot {{slotName}} is already defined.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const sourceCode = context.getSourceCode()
+    const documentFragment =
+      sourceCode.parserServices.getDocumentFragment &&
+      sourceCode.parserServices.getDocumentFragment()
+    if (!documentFragment) {
+      return {}
+    }
+    const scripts = documentFragment.children.filter(
+      (element) => utils.isVElement(element) && element.name === 'script'
+    )
+    if (scripts.every((script) => !utils.hasAttribute(script, 'lang', 'ts'))) {
+      return {}
+    }
+    const slotsDefined = new Set()
+
+    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 = memberNode.key.name
+              if (slotsDefined.has(slotName)) {
+                context.report({
+                  node: memberNode,
+                  messageId: 'alreadyDefinedSlot',
+                  data: {
+                    slotName
+                  }
+                })
+              } else {
+                slotsDefined.add(slotName)
+              }
+            }
+          }
+        }
+      }),
+      utils.executeOnVue(context, (obj) => {
+        const slotsProperty = utils.findProperty(obj, 'slots')
+        if (!slotsProperty) return
+
+        const slotsTypeHelper =
+          slotsProperty.value.typeAnnotation?.typeName.name === 'SlotsType' &&
+          slotsProperty.value.typeAnnotation
+        if (!slotsTypeHelper) return
+
+        const typeArguments =
+          'typeArguments' in slotsTypeHelper
+            ? slotsTypeHelper.typeArguments
+            : slotsTypeHelper.typeParameters
+        const param = /** @type {TypeNode|undefined} */ (
+          typeArguments?.params[0]
+        )
+        if (!param) return
+
+        if (param.type === 'TSTypeLiteral') {
+          for (const memberNode of param.members) {
+            const slotName = memberNode.key.name
+            if (slotsDefined.has(slotName)) {
+              context.report({
+                node: memberNode,
+                messageId: 'alreadyDefinedSlot',
+                data: {
+                  slotName
+                }
+              })
+            } else {
+              slotsDefined.add(slotName)
+            }
+          }
+        }
+      }),
+      utils.defineTemplateBodyVisitor(context, {
+        "VElement[name='slot']"(node) {
+          let slotName = 'default'
+
+          const slotNameAttr = utils.getAttribute(node, 'name')
+
+          if (slotNameAttr) {
+            slotName = slotNameAttr.value.value
+          }
+
+          if (!slotsDefined.has(slotName)) {
+            context.report({
+              node,
+              messageId: 'requireExplicitSlots'
+            })
+          }
+        }
+      })
+    )
+  }
+}


Test patch:
diff --git a/tests/lib/rules/require-explicit-slots.js b/tests/lib/rules/require-explicit-slots.js
new file mode 100644
index 00000000..1849ea9a
--- /dev/null
+++ b/tests/lib/rules/require-explicit-slots.js
@@ -0,0 +1,269 @@
+/**
+ * @author Mussin Benarbia
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/require-explicit-slots')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    parser: require.resolve('@typescript-eslint/parser'),
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('require-explicit-slots', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot />
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        default(props: { msg: string }): any
+      }>()
+      </script>`
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot></slot>
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        default(props: { msg: string }): any
+      }>()
+      </script>`
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo"></slot>
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        foo(props: { msg: string }): any
+      }>()
+      </script>`
+    },
+
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot />
+        </div>
+      </template>
+      <script lang="ts">
+      import { SlotsType } from 'vue'
+
+      defineComponent({
+        slots: Object as SlotsType<{
+          default: { msg: string }
+        }>,
+      })
+      </script>`
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot></slot>
+        </div>
+      </template>
+      <script lang="ts">
+      import { SlotsType } from 'vue'
+
+      defineComponent({
+        slots: Object as SlotsType<{
+          default: { msg: string }
+        }>,
+      })
+      </script>`
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo"></slot>
+        </div>
+      </template>
+      <script lang="ts">
+      import { SlotsType } from 'vue'
+
+      defineComponent({
+        slots: Object as SlotsType<{
+          foo(props: { msg: string }): any
+        }>,
+      })
+      </script>`
+    },
+    // does not report any error if the script is not TS
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo"></slot>
+        </div>
+      </template>
+      <script setup>
+      </script>`,
+      parserOptions: {
+        parser: null
+      }
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot />
+        </div>
+      </template>
+      <script setup lang="ts">
+      </script>`,
+      errors: [
+        {
+          message: 'Slots must be explicitly defined.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot></slot>
+        </div>
+      </template>
+      <script setup lang="ts">
+      </script>`,
+      errors: [
+        {
+          message: 'Slots must be explicitly defined.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo" />
+        </div>
+      </template>
+      <script setup lang="ts">
+      </script>`,
+      errors: [
+        {
+          message: 'Slots must be explicitly defined.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo" />
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        default(props: { msg: string }): any
+      }>()
+      </script>`,
+      errors: [
+        {
+          message: 'Slots must be explicitly defined.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo" />
+        </div>
+      </template>
+      <script lang="ts">
+      import { SlotsType } from 'vue'
+
+      defineComponent({
+        slots: Object as SlotsType<{
+          default: { msg: string }
+        }>,
+      })
+      </script>`,
+      errors: [
+        {
+          message: 'Slots must be explicitly defined.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo" />
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        foo(props: { msg: string }): any
+        foo(props: { msg: string }): any
+      }>()
+      </script>`,
+      errors: [
+        {
+          message: 'Slot foo is already defined.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot name="foo" />
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        foo(props: { msg: string }): any
+      }>()
+      defineSlots<{
+        default(props: { msg: string }): any,
+        foo(props: { msg: string }): any
+      }>()
+      </script>`,
+      errors: [
+        {
+          message: 'Slot foo is already defined.'
+        }
+      ]
+    }
+  ]
+})