Repository: francoismassart/eslint-plugin-tailwindcss
Pull request #41 (2021-07-02 13:36:38)

feat: add support for tagged templates
# Add support for tagged templates

## Description

I've added support for validating the rules against tagged templates. This means you could now validate e.g. ```const styles = myTag`block px-4`;```

Most of the code is copy-paste from the `CallExpression` implementations, so there could be potential for code de-duplication/refactoring here.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)
- [ ] This change requires a documentation update

## How Has This Been Tested?

I've duplicated some of the tests you've added for validating `CallExpression`s (`ctl(...)` & `clsx(...)`) and adjusted them to `TaggedTemplateExpression`s instead.

## Checklist:

- [X] My code follows the style guidelines of this project
- [X] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [X] My changes generate no new warnings
- [X] I have added tests that prove my fix is effective or that my feature works
- [X] New and existing unit tests pass locally with my changes
- [X] Any dependent changes have been merged and published in downstream modules
- [X] I have checked my code and corrected any misspellings



Patch:
diff --git a/lib/rules/classnames-order.js b/lib/rules/classnames-order.js
index db4b636..04477f1 100644
--- a/lib/rules/classnames-order.js
+++ b/lib/rules/classnames-order.js
@@ -58,6 +58,11 @@ module.exports = {
             default: true,
             type: 'boolean',
           },
+          tags: {
+            type: 'array',
+            items: { type: 'string', minLength: 0 },
+            uniqueItems: true,
+          },
         },
       },
     ],
@@ -65,6 +70,7 @@ module.exports = {
 
   create: function (context) {
     const callees = getOption(context, 'callees');
+    const tags = getOption(context, 'tags');
     const twConfig = getOption(context, 'config');
     const groupsConfig = getOption(context, 'groups');
     const groupByResponsive = getOption(context, 'groupByResponsive');
@@ -333,6 +339,13 @@ module.exports = {
           sortNodeArgumentValue(node, arg);
         });
       },
+      TaggedTemplateExpression: function (node) {
+        if (!tags.includes(node.tag.name)) {
+          return;
+        }
+
+        sortNodeArgumentValue(node, node.quasi);
+      },
     };
     const templateVisitor = {
       VAttribute: function (node) {
diff --git a/lib/rules/no-contradicting-classname.js b/lib/rules/no-contradicting-classname.js
index 4095a99..f464a79 100644
--- a/lib/rules/no-contradicting-classname.js
+++ b/lib/rules/no-contradicting-classname.js
@@ -46,6 +46,11 @@ module.exports = {
             default: 'tailwind.config.js',
             type: ['string', 'object'],
           },
+          tags: {
+            type: 'array',
+            items: { type: 'string', minLength: 0 },
+            uniqueItems: true,
+          },
         },
       },
     ],
@@ -53,6 +58,7 @@ module.exports = {
 
   create: function (context) {
     const callees = getOption(context, 'callees');
+    const tags = getOption(context, 'tags');
     const twConfig = getOption(context, 'config');
 
     const mergedConfig = customConfig.resolve(twConfig);
@@ -61,6 +67,9 @@ module.exports = {
     // Helpers
     //----------------------------------------------------------------------
 
+    // Init assets before sorting
+    const groups = groupUtil.getGroups(defaultGroups, mergedConfig);
+
     /**
      * Parse the classnames and report found conflicts
      * @param {Array} classNames
@@ -69,7 +78,6 @@ module.exports = {
     const parseForContradictingClassNames = (classNames, node) => {
       classNames = attrUtil.sanitizeClassnames(classNames);
       // Init assets before sorting
-      const groups = groupUtil.getGroups(defaultGroups, mergedConfig);
       const sorted = groupUtil.initGroupSlots(groups);
 
       // Move each classname inside its dedicated group
@@ -147,6 +155,24 @@ module.exports = {
         });
         parseForContradictingClassNames(allClassnamesForNode, node);
       },
+      TaggedTemplateExpression: function (node) {
+        if (!tags.includes(node.tag.name)) {
+          return;
+        }
+
+        const allClassnamesForNode = [];
+        const pushClasses = (classNames, targetNode) => {
+          if (targetNode === null) {
+            // Classnames should be parsed in isolation (e.g. conditional expressions)
+            parseForContradictingClassNames(classNames, node);
+          } else {
+            // Gather the classes prior to validation
+            allClassnamesForNode.push(...classNames);
+          }
+        };
+        astUtil.parseNodeRecursive(node, node.quasi, pushClasses, true);
+        parseForContradictingClassNames(allClassnamesForNode, node);
+      },
     };
 
     const templateVisitor = {
diff --git a/lib/rules/no-custom-classname.js b/lib/rules/no-custom-classname.js
index 2392119..7e56d99 100644
--- a/lib/rules/no-custom-classname.js
+++ b/lib/rules/no-custom-classname.js
@@ -52,6 +52,11 @@ module.exports = {
             items: { type: 'string', minLength: 0 },
             uniqueItems: true,
           },
+          tags: {
+            type: 'array',
+            items: { type: 'string', minLength: 0 },
+            uniqueItems: true,
+          },
           whitelist: {
             type: 'array',
             items: { type: 'string', minLength: 0 },
@@ -64,6 +69,7 @@ module.exports = {
 
   create: function (context) {
     const callees = getOption(context, 'callees');
+    const tags = getOption(context, 'tags');
     const twConfig = getOption(context, 'config');
     const cssFiles = getOption(context, 'cssFiles');
     const whitelist = getOption(context, 'whitelist');
@@ -74,6 +80,10 @@ module.exports = {
     // Helpers
     //----------------------------------------------------------------------
 
+    // Init assets before sorting
+    const groups = groupUtil.getGroups(defaultGroups, mergedConfig);
+    const classnamesFromFiles = getClassnamesFromCSS(cssFiles);
+
     /**
      * Parse the classnames and report found conflicts
      * @param {Array} classNames
@@ -81,9 +91,6 @@ module.exports = {
      */
     const parseForCustomClassNames = (classNames, node) => {
       classNames = attrUtil.sanitizeClassnames(classNames);
-      // Init assets before sorting
-      const groups = groupUtil.getGroups(defaultGroups, mergedConfig);
-      const classnamesFromFiles = getClassnamesFromCSS(cssFiles);
 
       classNames.forEach((className) => {
         const idx = groupUtil.getGroupIndex(className, groups, mergedConfig.separator);
@@ -128,6 +135,12 @@ module.exports = {
           astUtil.parseNodeRecursive(node, arg, parseForCustomClassNames);
         });
       },
+      TaggedTemplateExpression: function (node) {
+        if (!tags.includes(node.tag.name)) {
+          return;
+        }
+        astUtil.parseNodeRecursive(node, node.quasi, parseForCustomClassNames);
+      },
     };
 
     const templateVisitor = {
diff --git a/lib/util/settings.js b/lib/util/settings.js
index 0efe5bc..7068b22 100644
--- a/lib/util/settings.js
+++ b/lib/util/settings.js
@@ -27,6 +27,8 @@ function getOption(context, name) {
       return false;
     case 'removeDuplicates':
       return true;
+    case 'tags':
+      return [];
     case 'whitelist':
       return [];
   }


Test patch:
diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js
index 4305dc7..ad3deb6 100644
--- a/tests/lib/rules/classnames-order.js
+++ b/tests/lib/rules/classnames-order.js
@@ -140,6 +140,20 @@ ruleTester.run("classnames-order", rule, {
         },
       },
     },
+    {
+      code: `myTag\`
+        container
+        flex
+        w-12
+        sm:w-6
+        lg:w-4
+      \``,
+      options: [
+        {
+          tags: ["myTag"],
+        },
+      ],
+    },
   ],
   invalid: [
     {
@@ -525,5 +539,92 @@ ruleTester.run("classnames-order", rule, {
         },
       ],
     },
+    {
+      code: `
+      myTag\`
+        invalid
+        sm:w-6
+        container
+        w-12
+        flex
+        lg:w-4
+      \`;`,
+      output: `
+      myTag\`
+        container
+        flex
+        w-12
+        sm:w-6
+        lg:w-4
+        invalid
+      \`;`,
+      options: [
+        {
+          tags: ["myTag"],
+        },
+      ],
+      errors: errors,
+    },
+    {
+      code: `
+      const buttonClasses = myTag\`
+        \${fullWidth ? "w-12" : "w-6"}
+        container
+        \${fullWidth ? "sm:w-8" : "sm:w-4"}
+        lg:w-9
+        flex
+        \${hasError && "bg-red"}
+      \`;`,
+      output: `
+      const buttonClasses = myTag\`
+        \${fullWidth ? "w-12" : "w-6"}
+        container
+        \${fullWidth ? "sm:w-8" : "sm:w-4"}
+        flex
+        lg:w-9
+        \${hasError && "bg-red"}
+      \`;`,
+      options: [
+        {
+          tags: ["myTag"],
+        },
+      ],
+      errors: errors,
+    },
+    {
+      code: `
+      const buttonClasses = myTag\`
+        \${fullWidth ? "w-12" : "w-6"}
+        flex
+        container
+        \${fullWidth ? "sm:w-7" : "sm:w-4"}
+        lg:py-4
+        sm:py-6
+        \${hasError && "bg-red"}
+      \`;`,
+      output: `
+      const buttonClasses = myTag\`
+        \${fullWidth ? "w-12" : "w-6"}
+        container
+        flex
+        \${fullWidth ? "sm:w-7" : "sm:w-4"}
+        sm:py-6
+        lg:py-4
+        \${hasError && "bg-red"}
+      \`;`,
+      options: [
+        {
+          tags: ["myTag"],
+        },
+      ],
+      errors: [
+        {
+          messageId: "invalidOrder",
+        },
+        {
+          messageId: "invalidOrder",
+        },
+      ],
+    },
   ],
 });
diff --git a/tests/lib/rules/no-contradicting-classname.js b/tests/lib/rules/no-contradicting-classname.js
index 94780cf..2fedc26 100644
--- a/tests/lib/rules/no-contradicting-classname.js
+++ b/tests/lib/rules/no-contradicting-classname.js
@@ -88,6 +88,37 @@ ruleTester.run("no-contradicting-classname", rule, {
       \`)
       `,
     },
+    {
+      code: `
+      myTag\`
+        text-white
+        rounded-md
+        py-5
+        px-10
+        text-sm
+        \${
+          type === 'primary' &&
+          \`
+            bg-black
+            hover:bg-blue
+          \`
+        }
+        \${
+          type === 'secondary' &&
+          \`
+            bg-transparent
+            hover:bg-green
+          \`
+        }
+        disabled:cursor-not-allowed
+      \`
+      `,
+      options: [
+        {
+          tags: ["myTag"],
+        },
+      ],
+    },
   ],
 
   invalid: [
@@ -271,5 +302,82 @@ ruleTester.run("no-contradicting-classname", rule, {
         },
       ],
     },
+
+    {
+      code: `
+      myTag\`
+        invalid bis
+        sm:w-6
+        w-8
+        container
+        w-12
+        flex
+        lg:w-4
+      \`;`,
+      options: [{ tags: ["myTag"] }],
+      errors: [
+        {
+          messageId: "conflictingClassnames",
+          data: {
+            classnames: "w-8, w-12",
+          },
+        },
+      ],
+    },
+    {
+      code: `
+      myTag\`
+        px-2
+        px-4
+        \${
+          !isDisabled &&
+          \`
+            py-1
+            py-2
+          \`
+        }
+        \${
+          isDisabled &&
+          \`
+            w-1
+            w-2
+          \`
+        }
+      \`
+      `,
+      options: [{ tags: ["myTag"] }],
+      errors: [
+        {
+          messageId: "conflictingClassnames",
+          data: {
+            classnames: "py-1, py-2",
+          },
+        },
+        {
+          messageId: "conflictingClassnames",
+          data: {
+            classnames: "w-1, w-2",
+          },
+        },
+        {
+          messageId: "conflictingClassnames",
+          data: {
+            classnames: "px-2, px-4",
+          },
+        },
+      ],
+    },
+    {
+      code: `myTag\`\${enabled && "px-2 px-0"}\``,
+      options: [{ tags: ["myTag"] }],
+      errors: [
+        {
+          messageId: "conflictingClassnames",
+          data: {
+            classnames: "px-2, px-0",
+          },
+        },
+      ],
+    },
   ],
 });
diff --git a/tests/lib/rules/no-custom-classname.js b/tests/lib/rules/no-custom-classname.js
index 60be820..addfa36 100644
--- a/tests/lib/rules/no-custom-classname.js
+++ b/tests/lib/rules/no-custom-classname.js
@@ -131,6 +131,19 @@ ruleTester.run("no-custom-classname", rule, {
         },
       ],
     },
+
+    {
+      code: `
+      myTag\`
+        sm:w-6
+        w-8
+        container
+        w-12
+        flex
+        lg:w-4
+      \`;`,
+      options: [{ tags: ["myTag"] }],
+    },
   ],
 
   invalid: [
@@ -351,5 +364,80 @@ ruleTester.run("no-custom-classname", rule, {
         },
       ],
     },
+
+    {
+      code: `
+      myTag\`
+        sm:w-6
+        hello
+        w-8
+        container
+        w-12
+        world
+        flex
+        lg:w-4
+      \`;`,
+      options: [{ tags: ["myTag"] }],
+      errors: [
+        {
+          messageId: "customClassnameDetected",
+          data: {
+            classname: "hello",
+          },
+        },
+        {
+          messageId: "customClassnameDetected",
+          data: {
+            classname: "world",
+          },
+        },
+      ],
+    },
+    {
+      code: `
+      myTag\`
+        px-4
+        custom-1
+        py-1
+        \${
+          !isDisabled &&
+          \`
+            lg:focus:ring-1
+            custom-2
+            focus:ring-2
+          \`
+        }
+        \${
+          isDisabled &&
+          \`
+            lg:opacity-25
+            custom-3
+            opacity-50
+          \`
+        }
+      \`
+      `,
+      options: [{ tags: ["myTag"] }],
+      errors: [
+        {
+          messageId: "customClassnameDetected",
+          data: {
+            classname: "custom-2",
+          },
+        },
+        {
+          messageId: "customClassnameDetected",
+          data: {
+            classname: "custom-3",
+          },
+        },
+        {
+          messageId: "customClassnameDetected",
+          data: {
+            classname: "custom-1",
+          },
+        },
+      ],
+    },
   ],
 });