Repository: vuejs/eslint-plugin-vue
Pull request #1639 (2021-09-27 18:02:30)

Add vue/no-restricted-class rule
I had to refactor the rule somewhat from my original implementation to make it work with Typescript. This made it a bit harder to read, so any suggestions for improving that are welcome.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 85f6e661..2f89e3a5 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -310,6 +310,7 @@ For example:
 | [vue/no-reserved-component-names](./no-reserved-component-names.md) | disallow the use of reserved names in component definitions |  |
 | [vue/no-restricted-block](./no-restricted-block.md) | disallow specific block |  |
 | [vue/no-restricted-call-after-await](./no-restricted-call-after-await.md) | disallow asynchronously called restricted methods |  |
+| [vue/no-restricted-class](./no-restricted-class.md) | disallow specific classes in Vue components |  |
 | [vue/no-restricted-component-options](./no-restricted-component-options.md) | disallow specific component option |  |
 | [vue/no-restricted-custom-event](./no-restricted-custom-event.md) | disallow specific custom event |  |
 | [vue/no-restricted-props](./no-restricted-props.md) | disallow specific props |  |
diff --git a/docs/rules/no-restricted-class.md b/docs/rules/no-restricted-class.md
new file mode 100644
index 00000000..c7aebce6
--- /dev/null
+++ b/docs/rules/no-restricted-class.md
@@ -0,0 +1,79 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-restricted-class
+description: disallow specific classes in Vue components
+---
+# vue/no-restricted-class
+
+> disallow specific classes in Vue components
+
+- :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 lets you specify a list of classes that you don't want to allow in your templates.
+
+## :wrench: Options
+
+The simplest way to specify a list of forbidden classes is to pass it directly
+in the rule configuration.
+
+```json
+{
+  "vue/no-restricted-props": ["error", "forbidden", "forbidden-two", "forbidden-three"]
+}
+```
+
+<eslint-code-block :rules="{'vue/no-restricted-class': ['error', 'forbidden']}">
+
+```vue
+<template>
+  <!-- ✗ BAD -->
+  <div class="forbidden" />
+  <div :class="{forbidden: someBoolean}" />
+  <div :class="`forbidden ${someString}`" />
+  <div :class="'forbidden'" />
+  <div :class="'forbidden ' + someString" />
+  <div :class="[someString, 'forbidden']" />
+  <!-- ✗ GOOD -->
+  <div class="allowed-class" />
+</template>
+
+<script>
+export default {
+  props: {
+    someBoolean: Boolean,
+    someString: String,
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+::: warning Note
+This rule will only detect classes that are used as strings in your templates. Passing classes via
+variables, like below, will not be detected by this rule.
+
+```vue
+<template>
+  <div :class="classes" />
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      classes: "forbidden"
+    }
+  }
+}
+</script>
+```
+:::
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-restricted-class.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-restricted-class.js)
diff --git a/lib/index.js b/lib/index.js
index 0015fb57..ef111732 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -101,6 +101,7 @@ module.exports = {
     'no-reserved-keys': require('./rules/no-reserved-keys'),
     'no-restricted-block': require('./rules/no-restricted-block'),
     'no-restricted-call-after-await': require('./rules/no-restricted-call-after-await'),
+    'no-restricted-class': require('./rules/no-restricted-class'),
     'no-restricted-component-options': require('./rules/no-restricted-component-options'),
     'no-restricted-custom-event': require('./rules/no-restricted-custom-event'),
     'no-restricted-props': require('./rules/no-restricted-props'),
diff --git a/lib/rules/no-restricted-class.js b/lib/rules/no-restricted-class.js
new file mode 100644
index 00000000..b1dcccb0
--- /dev/null
+++ b/lib/rules/no-restricted-class.js
@@ -0,0 +1,154 @@
+/**
+ * @fileoverview Forbid certain classes from being used
+ * @author Tao Bojlen
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+/**
+ * Report a forbidden class
+ * @param {string} className
+ * @param {*} node
+ * @param {RuleContext} context
+ * @param {Set<string>} forbiddenClasses
+ */
+const reportForbiddenClass = (className, node, context, forbiddenClasses) => {
+  if (forbiddenClasses.has(className)) {
+    const loc = node.value ? node.value.loc : node.loc
+    context.report({
+      node,
+      loc,
+      messageId: 'forbiddenClass',
+      data: {
+        class: className
+      }
+    })
+  }
+}
+
+/**
+ * @param {Expression} node
+ * @param {boolean} [textOnly]
+ * @returns {IterableIterator<{ className:string, reportNode: ESNode }>}
+ */
+function* extractClassNames(node, textOnly) {
+  if (node.type === 'Literal') {
+    yield* `${node.value}`
+      .split(/\s+/)
+      .map((className) => ({ className, reportNode: node }))
+    return
+  }
+  if (node.type === 'TemplateLiteral') {
+    for (const templateElement of node.quasis) {
+      yield* templateElement.value.cooked
+        .split(/\s+/)
+        .map((className) => ({ className, reportNode: templateElement }))
+    }
+    for (const expr of node.expressions) {
+      yield* extractClassNames(expr, true)
+    }
+    return
+  }
+  if (node.type === 'BinaryExpression') {
+    if (node.operator !== '+') {
+      return
+    }
+    yield* extractClassNames(node.left, true)
+    yield* extractClassNames(node.right, true)
+    return
+  }
+  if (textOnly) {
+    return
+  }
+  if (node.type === 'ObjectExpression') {
+    for (const prop of node.properties) {
+      if (prop.type !== 'Property') {
+        continue
+      }
+      const classNames = utils.getStaticPropertyName(prop)
+      if (!classNames) {
+        continue
+      }
+      yield* classNames
+        .split(/\s+/)
+        .map((className) => ({ className, reportNode: prop.key }))
+    }
+    return
+  }
+  if (node.type === 'ArrayExpression') {
+    for (const element of node.elements) {
+      if (element == null) {
+        continue
+      }
+      if (element.type === 'SpreadElement') {
+        continue
+      }
+      yield* extractClassNames(element)
+    }
+    return
+  }
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'disallow specific classes in Vue components',
+      url: 'https://eslint.vuejs.org/rules/no-restricted-class.html',
+      categories: undefined
+    },
+    fixable: null,
+    messages: {
+      forbiddenClass: "'{{class}}' class is not allowed."
+    },
+    schema: {
+      type: 'array',
+      items: {
+        type: 'string'
+      }
+    }
+  },
+
+  /** @param {RuleContext} context */
+  create(context) {
+    const forbiddenClasses = new Set(context.options || [])
+
+    return utils.defineTemplateBodyVisitor(context, {
+      /**
+       * @param {VAttribute & { value: VLiteral } } node
+       */
+      'VAttribute[directive=false][key.name="class"]'(node) {
+        node.value.value
+          .split(/\s+/)
+          .forEach((className) =>
+            reportForbiddenClass(className, node, context, forbiddenClasses)
+          )
+      },
+
+      /** @param {VExpressionContainer} node */
+      "VAttribute[directive=true][key.name.name='bind'][key.argument.name='class'] > VExpressionContainer.value"(
+        node
+      ) {
+        if (!node.expression) {
+          return
+        }
+
+        for (const { className, reportNode } of extractClassNames(
+          /** @type {Expression} */ (node.expression)
+        )) {
+          reportForbiddenClass(className, reportNode, context, forbiddenClasses)
+        }
+      }
+    })
+  }
+}


Test patch:
diff --git a/tests/lib/rules/no-restricted-class.js b/tests/lib/rules/no-restricted-class.js
new file mode 100644
index 00000000..baf39778
--- /dev/null
+++ b/tests/lib/rules/no-restricted-class.js
@@ -0,0 +1,118 @@
+/**
+ * @author Tao Bojlen
+ */
+
+'use strict'
+
+const rule = require('../../../lib/rules/no-restricted-class')
+const RuleTester = require('eslint').RuleTester
+
+const ruleTester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
+})
+
+ruleTester.run('no-restricted-class', rule, {
+  valid: [
+    { code: `<template><div class="allowed">Content</div></template>` },
+    {
+      code: `<template><div class="allowed"">Content</div></template>`,
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="'allowed' + forbidden">Content</div></template>`,
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div @class="forbidden">Content</div></template>`,
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="'' + {forbidden: true}">Content</div></template>`,
+      options: ['forbidden']
+    }
+  ],
+
+  invalid: [
+    {
+      code: `<template><div class="forbidden allowed" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'VAttribute'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="'forbidden' + ' ' + 'allowed' + someVar" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'Literal'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="{'forbidden': someBool, someVar: true}" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'Literal'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="{forbidden: someBool}" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'Identifier'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: '<template><div :class="`forbidden ${someVar}`" /></template>',
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'TemplateElement'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="'forbidden'" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'Literal'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="['forbidden', 'allowed']" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'Literal'
+        }
+      ],
+      options: ['forbidden']
+    },
+    {
+      code: `<template><div :class="['allowed forbidden', someString]" /></template>`,
+      errors: [
+        {
+          message: "'forbidden' class is not allowed.",
+          type: 'Literal'
+        }
+      ],
+      options: ['forbidden']
+    }
+  ]
+})