Repository: francoismassart/eslint-plugin-tailwindcss
Pull request #2 (2021-03-26 21:41:58)

rules/no-custom-classname
- REFACT do avoid code duplication between rules
- New rule to detect custom classes


Patch:
diff --git a/README.md b/README.md
index 99593bc..2cf2358 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,8 @@ Then configure the rules you want to use under the rules section.
 ```json
 {
     "rules": {
-        "tailwindcss/classnames-order": 2
+        "tailwindcss/classnames-order": 2,
+        "tailwindcss/no-custom-classname": 2,
     }
 }
 ```
@@ -45,11 +46,11 @@ Learn more about [Configuring Rules](https://eslint.org/docs/user-guide/configur
 
 ## Supported Rules
 
-* `classnames-order`: order classnames by target properties then by variants (`[size:][theme:][state:]`)
+* [`classnames-order`](docs/rules/classnames-order.md): order classnames by target properties then by variants (`[size:][theme:][state:]`)
+* [`no-custom-classname`](docs/rules/no-custom-classname.md): only allow classnames from Tailwind CSS
 
 ## Upcoming Rules
 
-* `no-custom-classname`: only allow classnames from Tailwind CSS
 * `no-contradicting-classname`: e.g. avoid `p-2 p-3`, different Tailwind CSS classnames (`pt-2` & `pt-3`) but targeting the same property
 * `no-redundant-variant`: e.g. avoid `mx-5 sm:mx-5`, no need to redefine `mx` in `sm:` variant as it uses the same value (`5`)
 
@@ -64,4 +65,4 @@ I wrote this plugin after searching for existing tools which perform the same ta
 
 You are welcome to contribute to this project by reporting issues, feature requests or even opening Pull Requests.
 
-Learn more about [contributing to ESLint-plugin-TailwindCSS](CONTRIBUTING.md).
\ No newline at end of file
+Learn more about [contributing to ESLint-plugin-TailwindCSS](CONTRIBUTING.md).
diff --git a/docs/rules/no-custom-classname.md b/docs/rules/no-custom-classname.md
new file mode 100644
index 0000000..ff96f98
--- /dev/null
+++ b/docs/rules/no-custom-classname.md
@@ -0,0 +1,17 @@
+# Detect classnames which do not belong to Tailwind CSS (no-custom-classname)
+
+Enable this rule if you do not want to accept using classnames that are not defined in [Tailwind CSS](https://tailwindcss.com/).
+
+## Rule Details
+
+Examples of **incorrect** code for this rule:
+
+```html
+<div class="w-12 my-custom">my-custom is not defined in Tailwind CSS!</div>
+```
+
+Examples of **correct** code for this rule:
+
+```html
+<div class="container box-content lg:box-border">Only Tailwind CSS classnames</div>
+```
diff --git a/lib/rules/classnames-order.js b/lib/rules/classnames-order.js
index 726a1ed..5e4b494 100644
--- a/lib/rules/classnames-order.js
+++ b/lib/rules/classnames-order.js
@@ -4,19 +4,31 @@
  */
 'use strict';
 
+const docsUrl = require('../util/docsUrl');
 const defaultGroups = require('../config/groups').groups;
+const astUtil = require('../util/ast');
+const attrUtil = require('../util/attr');
+const groupUtil = require('../util/groupMethods');
+const removeDuplicatesFromArray = require('../util/removeDuplicatesFromArray');
 
 //------------------------------------------------------------------------------
 // Rule Definition
 //------------------------------------------------------------------------------
 
+// Predefine message for use in context.report conditional.
+// messageId will still be usable in tests.
+const INVALID_CLASSNAMES_ORDER_MSG = 'Invalid Tailwind CSS classenames order';
+
 module.exports = {
   meta: {
     docs: {
       description: 'Enforce a consistent and logical order of the Tailwind CSS classnames',
       category: 'Stylistic Issues',
       recommended: false,
-      // url: 
+      url: docsUrl('classnames-order'),
+    },
+    messages: {
+      invalidOrder: INVALID_CLASSNAMES_ORDER_MSG
     },
     fixable: 'code',
     schema: [{
@@ -24,11 +36,11 @@ module.exports = {
       properties: {
         prependCustom: {
           default: false,
-          type: 'boolean'
+          type: 'boolean',
         },
         removeDuplicates: {
           default: true,
-          type: 'boolean'
+          type: 'boolean',
         }
       }
     }]
@@ -50,62 +62,6 @@ module.exports = {
     //----------------------------------------------------------------------
     // Helpers
     //----------------------------------------------------------------------
-    const isClassAttribute = (node) => node.name && /^class(Name)?$/.test(node.name.name);
-
-    const isLitteralAttributeValue = (node) => {
-      if (node.value && node.value.type === 'Literal') {
-        // No support for dynamic or conditional...
-        return !/\{|\?|\}/.test(node.value.value)
-      }
-      return false;
-    };
-
-    const removeDuplicatesFromArray = el => [...new Set(el)];
-
-    const getClassNamesFromAttribute = (attr) => {
-      // Fix multiple spaces
-      attr = attr.replace(/\s{2,}/g, ' ');
-      // Array of unique classNames
-      let classesArray = attr.split(' ');
-      return classesArray;
-    };
-
-    const getGroups = (config) => {
-      const groups = [];
-      config.forEach(group => {
-        // e.g. SIZING or SPACING
-        group.members.forEach(prop => {
-          // e.g. Width or Padding
-          if (typeof prop.members === 'string') {
-            // Unique property, like `width` limited to one value
-            groups.push(prop.members);
-          } else {
-            // Multiple properties, like `padding`, `padding-top`...
-            prop.members.forEach(subprop => {
-              groups.push(subprop.members);
-            })
-          }
-        });
-      });
-      return groups;
-    };
-
-    const initGroupSlots = (groups) => {
-      const slots = [];
-      groups.forEach(g => slots.push([]));
-      return slots;
-    };
-
-    const getSuffix = (className) => className.split(':').pop();
-
-    const getGroupIndex = (name, arr) => {
-      const classSuffix = getSuffix(name);
-      let idx = arr.findIndex(pattern => {
-        const classRe = new RegExp(`^${pattern}$`);
-        return classRe.test(classSuffix)
-      }, classSuffix);
-      return idx;
-    };
 
     const getPrefixIndex = (str, arr, beginning = false) => {
       const start = beginning ? '^' : '';
@@ -157,17 +113,12 @@ module.exports = {
     const removeDuplicates = !(configuration.removeDuplicates === false);
     return {
       JSXAttribute: function (node) {
-        if (!isClassAttribute(node)) {
-          // Only run for class[Name] attributes
-          return;
-        }
-        if (!isLitteralAttributeValue(node)) {
-          // No support for dynamic or conditional classnames
+        if (!astUtil.isValidJSXAttribute(node)) {
           return;
         }
 
         const originalClassNamesValue = node.value.value;
-        let classNames = getClassNamesFromAttribute(originalClassNamesValue);
+        let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue);
         if (removeDuplicates) {
           classNames = removeDuplicatesFromArray(classNames);
         }
@@ -177,13 +128,13 @@ module.exports = {
         }
 
         // Init assets before sorting
-        const groups = getGroups(groupsConfig);
-        const sorted = initGroupSlots(groups);
+        const groups = groupUtil.getGroups(groupsConfig);
+        const sorted = groupUtil.initGroupSlots(groups);
         const extras = [];
 
         // Move each classname inside its dedicated group
         classNames.forEach(className => {
-          const idx = getGroupIndex(className, groups);
+          const idx = groupUtil.getGroupIndex(className, groups);
           if (idx > -1) {
             sorted[idx].push(className);
           } else {
@@ -203,7 +154,7 @@ module.exports = {
         if (originalClassNamesValue !== validatedClassNamesValue) {
           context.report({
             node: node,
-            message: 'Invalid Tailwind CSS classenames order',
+            messageId: 'invalidOrder',
             fix: function (fixer) {
               return fixer.replaceText(node.value, `"${validatedClassNamesValue}"`);
             }
diff --git a/lib/rules/no-custom-classname.js b/lib/rules/no-custom-classname.js
new file mode 100644
index 0000000..500ce93
--- /dev/null
+++ b/lib/rules/no-custom-classname.js
@@ -0,0 +1,84 @@
+/**
+ * @fileoverview Detect classnames which do not belong to Tailwind CSS
+ * @author no-custom-classname
+ */
+'use strict';
+
+const docsUrl = require('../util/docsUrl');
+const defaultGroups = require('../config/groups').groups;
+const astUtil = require('../util/ast');
+const attrUtil = require('../util/attr');
+const groupUtil = require('../util/groupMethods');
+const removeDuplicatesFromArray = require('../util/removeDuplicatesFromArray');
+
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+// Predefine message for use in context.report conditional.
+// messageId will still be usable in tests.
+const CUSTOM_CLASSNAME_DETECTED_MSG = `Classname '{{classname}}' is not a Tailwind CSS class!`;
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Detect classnames which do not belong to Tailwind CSS',
+      category: 'Best Practices',
+      recommended: false,
+      url: docsUrl('no-custom-classname'),
+    },
+    messages: {
+      customClassnameDetected: CUSTOM_CLASSNAME_DETECTED_MSG
+    },
+    fixable: 'code',
+    schema: []
+  },
+
+  create: function (context) {
+
+    // variables should be defined here
+
+    //----------------------------------------------------------------------
+    // Helpers
+    //----------------------------------------------------------------------
+
+    // any helper functions should go here or else delete this section
+
+    //----------------------------------------------------------------------
+    // Public
+    //----------------------------------------------------------------------
+
+    return {
+      JSXAttribute: function (node) {
+        if (!astUtil.isValidJSXAttribute(node)) {
+          return;
+        }
+
+        const originalClassNamesValue = node.value.value;
+        let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue);
+        classNames = removeDuplicatesFromArray(classNames);
+        if (classNames.length <= 1) {
+          // Don't run for a single or empty className
+          return;
+        }
+
+        const groups = groupUtil.getGroups(defaultGroups);
+
+        classNames.forEach(className => {
+          const idx = groupUtil.getGroupIndex(className, groups);
+          if (idx === -1) {
+            context.report({
+              node,
+              messageId: 'customClassnameDetected',
+              data: {
+                classname: className
+              }
+            });
+          }
+        });
+
+      },
+    };
+  }
+};
diff --git a/lib/util/ast.js b/lib/util/ast.js
new file mode 100644
index 0000000..3528e0e
--- /dev/null
+++ b/lib/util/ast.js
@@ -0,0 +1,53 @@
+/**
+ * @fileoverview Utility functions for AST
+ */
+
+'use strict';
+
+/**
+ * Find out if node is `class` or `className`
+ *
+ * @param {ASTNode} node The AST node being checked
+ * @returns {Boolean}
+ */
+function isClassAttribute(node) {
+  return node.name && /^class(Name)?$/.test(node.name.name);
+}
+
+/**
+ * Find out if node's value attribute is just simple text
+ *
+ * @param {ASTNode} node The AST node being checked
+ * @returns {Boolean}
+ */
+function isLitteralAttributeValue(node) {
+  if (node.value && node.value.type === 'Literal') {
+    // No support for dynamic or conditional...
+    return !/\{|\?|\}/.test(node.value.value)
+  }
+  return false;
+}
+
+/**
+ * Find out if the node is a valid candidate for our rules
+ *
+ * @param {ASTNode} node The AST node being checked
+ * @returns {Boolean}
+ */
+function isValidJSXAttribute(node) {
+  if (!isClassAttribute(node)) {
+    // Only run for class[Name] attributes
+    return false;
+  }
+  if (!isLitteralAttributeValue(node)) {
+    // No support for dynamic or conditional classnames
+    return false;
+  }
+  return true;
+}
+
+module.exports = {
+  isClassAttribute,
+  isLitteralAttributeValue,
+  isValidJSXAttribute,
+};
\ No newline at end of file
diff --git a/lib/util/attr.js b/lib/util/attr.js
new file mode 100644
index 0000000..7fcd751
--- /dev/null
+++ b/lib/util/attr.js
@@ -0,0 +1,17 @@
+/**
+ * Convert a class[Name] attribute's values to an array of classnames
+ *
+ * @param {string} attr The attribute's value
+ * @returns {Boolean}
+ */
+ function getClassNamesFromAttribute(attr) {
+  // Fix multiple spaces
+  attr = attr.replace(/\s{2,}/g, ' ');
+  // Array of unique classNames
+  let classesArray = attr.split(' ');
+  return classesArray;
+}
+
+module.exports = {
+  getClassNamesFromAttribute,
+};
\ No newline at end of file
diff --git a/lib/util/docsUrl.js b/lib/util/docsUrl.js
new file mode 100644
index 0000000..496e42c
--- /dev/null
+++ b/lib/util/docsUrl.js
@@ -0,0 +1,8 @@
+'use strict';
+
+// Copied from https://github.com/yannickcr/eslint-plugin-react/blob/master/lib/util/docsUrl.js
+function docsUrl(ruleName) {
+  return `https://github.com/francoismassart/eslint-plugin-tailwindcss/tree/master/docs/rules/${ruleName}.md`;
+}
+
+module.exports = docsUrl;
diff --git a/lib/util/groupMethods.js b/lib/util/groupMethods.js
new file mode 100644
index 0000000..2700b5c
--- /dev/null
+++ b/lib/util/groupMethods.js
@@ -0,0 +1,69 @@
+/**
+ * @fileoverview Utilities used for grouping classnames
+ */
+
+'use strict';
+
+/**
+ * Generates a flatten array from the groups config
+ *
+ * @param {Array} config The array of objects containing the regex
+ * @returns {Array} Flatten array 
+ */
+function getGroups(config) {
+  const groups = [];
+  config.forEach(group => {
+    // e.g. SIZING or SPACING
+    group.members.forEach(prop => {
+      // e.g. Width or Padding
+      if (typeof prop.members === 'string') {
+        // Unique property, like `width` limited to one value
+        groups.push(prop.members);
+      } else {
+        // Multiple properties, like `padding`, `padding-top`...
+        prop.members.forEach(subprop => {
+          groups.push(subprop.members);
+        })
+      }
+    });
+  });
+  return groups;
+}
+
+/**
+ * Generates an array of empty arrays prior to grouping
+ *
+ * @param {Array} groups The array of objects containing the regex
+ * @returns {Array} Array of empty arrays 
+ */
+function initGroupSlots(groups) {
+  const slots = [];
+  groups.forEach(g => slots.push([]));
+  return slots;
+}
+
+/**
+ * Searches for a match between classname and Tailwind CSS group
+ *
+ * @param {Array} name The target classname
+ * @param {Array} arr The flatten array containing the regex
+ * @returns {Array} Array of empty arrays 
+ */
+function getGroupIndex(name, arr) {
+  const classSuffix = getSuffix(name);
+  let idx = arr.findIndex(pattern => {
+    const classRe = new RegExp(`^${pattern}$`);
+    return classRe.test(classSuffix)
+  }, classSuffix);
+  return idx;
+}
+
+function getSuffix(className) {
+  return className.split(':').pop();
+}
+
+module.exports = {
+  getGroups,
+  initGroupSlots,
+  getGroupIndex,
+};
diff --git a/lib/util/removeDuplicatesFromArray.js b/lib/util/removeDuplicatesFromArray.js
new file mode 100644
index 0000000..6301fe4
--- /dev/null
+++ b/lib/util/removeDuplicatesFromArray.js
@@ -0,0 +1,7 @@
+'use strict';
+
+function removeDuplicatesFromArray(arr) {
+  return [...new Set(arr)];
+}
+
+module.exports = removeDuplicatesFromArray;


Test patch:
diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js
index 97986df..3a8f346 100644
--- a/tests/lib/rules/classnames-order.js
+++ b/tests/lib/rules/classnames-order.js
@@ -8,9 +8,8 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-var rule = require('../../../lib/rules/classnames-order'),
-
-  RuleTester = require('eslint').RuleTester;
+var rule = require('../../../lib/rules/classnames-order');
+var RuleTester = require('eslint').RuleTester;
 
 
 //------------------------------------------------------------------------------
@@ -28,7 +27,7 @@ var parserOptions = {
 var ruleTester = new RuleTester({ parserOptions });
 
 var errors = [{
-  message: 'Invalid Tailwind CSS classenames order',
+  messageId: 'invalidOrder',
 }];
 
 ruleTester.run('classnames-order', rule, {
diff --git a/tests/lib/rules/no-custom-classname.js b/tests/lib/rules/no-custom-classname.js
new file mode 100644
index 0000000..13ec520
--- /dev/null
+++ b/tests/lib/rules/no-custom-classname.js
@@ -0,0 +1,65 @@
+/**
+ * @fileoverview Detect classnames which do not belong to Tailwind CSS
+ * @author no-custom-classname
+ */
+'use strict';
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+var rule = require('../../../lib/rules/no-custom-classname');
+var RuleTester = require('eslint').RuleTester;
+
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+var parserOptions = {
+  ecmaVersion: 2019,
+  sourceType: 'module',
+  ecmaFeatures: {
+    jsx: true,
+  },
+};
+
+var ruleTester = new RuleTester({ parserOptions });
+
+ruleTester.run('no-custom-classname', rule, {
+
+  valid: [
+    {
+      code: `<div class="container box-content lg:box-border">Only Tailwind CSS classnames</div>`,
+    },
+  ],
+
+  invalid: [
+    {
+      code: `<div class="w-12 my-custom">my-custom is not defined in Tailwind CSS!</div>`,
+      errors: [{
+        messageId: 'customClassnameDetected',
+        data: {
+          classname: 'my-custom'
+        }
+    }]
+    },
+    {
+      code: `<div class="hello world">2 classnames are not defined in Tailwind CSS!</div>`,
+      errors: [
+        {
+          messageId: 'customClassnameDetected',
+          data: {
+            classname: 'hello'
+          }
+        },
+        {
+          messageId: 'customClassnameDetected',
+          data: {
+            classname: 'world'
+          }
+        },
+      ]
+    },
+  ]
+});