Repository: francoismassart/eslint-plugin-tailwindcss
Pull request #126 (2022-02-22 09:34:27)

feat: add `officialSorting` option for `classnames-order`


Patch:
diff --git a/README.md b/README.md
index 07479a4..dd4ef0f 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ If you enjoy my work you can:
 
 ## Latest changelog
 
+- New strategy for whitespaces and linebreaks: the plugin will attempt to leave them intact
+- New option `officialSorting` for [`classnames-order`](docs/rules/classnames-order.md#officialsorting-default-false) can be set to `true` in order to use the same ordering order as the official [`prettier-plugin-tailwindcss`](https://www.npmjs.com/package/prettier-plugin-tailwindcss)
 - FIX: `enforces-shorthand` rule [fixer](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/120) and [fix prefix](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/121)
 - FIX: [`enforces-shorthand` rule loses the importance flag](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/114)
 - New rule: [`enforces-negative-arbitrary-values`](docs/rules/enforces-negative-arbitrary-values.md): prefers `top-[-5px]` instead of `-top-[5px]`
@@ -158,6 +160,7 @@ All these settings have nice default values that are explained in each rules' do
       "cssFilesRefreshRate": 5_000,
       "groupByResponsive": true,
       "groups": defaultGroups, // imported from groups.js
+      "officialSorting": false,
       "prependCustom": false,
       "removeDuplicates": true,
       "whitelist": []
diff --git a/docs/rules/classnames-order.md b/docs/rules/classnames-order.md
index 0b7410d..ffbfeb0 100644
--- a/docs/rules/classnames-order.md
+++ b/docs/rules/classnames-order.md
@@ -27,6 +27,7 @@ Examples of **correct** code for this rule:
   "config": <string>|<object>,
   "groupByResponsive": <boolean>,
   "groups": Array<object>,
+  "officialSorting": <boolean>,
   "prependCustom": <boolean>,
   "removeDuplicates": <boolean>,
   "tags": Array<string>,
@@ -115,6 +116,10 @@ const customGroups = require('custom-groups').groups;
 ...
 ```
 
+### `officialSorting` (default: `false`)
+
+Set `officialSorting` to `true` if you want to use the same ordering rules as the official plugin `prettier-plugin-tailwindcss`. Enabling this settings will cause `groupByResponsive`, `groups`, `prependCustom` and `removeDuplicates` options to be ignored.
+
 ### `prependCustom` (default: `false`)
 
 By default, classnames which doesn't belong to Tailwind CSS will be pushed at the end. Set `prependCustom` to `true` if you prefer to move them at the beginning.
diff --git a/lib/rules/classnames-order.js b/lib/rules/classnames-order.js
index e557263..039b1c3 100644
--- a/lib/rules/classnames-order.js
+++ b/lib/rules/classnames-order.js
@@ -7,11 +7,12 @@
 const docsUrl = require('../util/docsUrl');
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
-const removeDuplicatesFromArray = require('../util/removeDuplicatesFromArray');
+const removeDuplicatesFromClassnamesAndWhitespaces = require('../util/removeDuplicatesFromClassnamesAndWhitespaces');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
+const order = require('../util/prettier/order');
+const createContextFallback = require('tailwindcss/lib/lib/setupContextUtils').createContext;
 
 //------------------------------------------------------------------------------
 // Rule Definition
@@ -54,6 +55,10 @@ module.exports = {
             type: 'array',
             items: { type: 'object' },
           },
+          officialSorting: {
+            default: false,
+            type: 'boolean',
+          },
           prependCustom: {
             default: false,
             type: 'boolean',
@@ -78,10 +83,12 @@ module.exports = {
     const twConfig = getOption(context, 'config');
     const groupsConfig = getOption(context, 'groups');
     const groupByResponsive = getOption(context, 'groupByResponsive');
+    const officialSorting = getOption(context, 'officialSorting');
     const prependCustom = getOption(context, 'prependCustom');
     const removeDuplicates = getOption(context, 'removeDuplicates');
 
     const mergedConfig = customConfig.resolve(twConfig);
+    const contextFallback = officialSorting ? createContextFallback(mergedConfig) : null;
 
     //----------------------------------------------------------------------
     // Helpers
@@ -129,7 +136,7 @@ module.exports = {
         classnamesByResponsive.push([]);
       });
       classNames.forEach((cls) => {
-        const idx = parseInt(getSpecificity(attrUtil.cleanClassname(cls), responsiveVariants, true), 10);
+        const idx = parseInt(getSpecificity(cls, responsiveVariants, true), 10);
         classnamesByResponsive[idx].push(cls);
       });
       return classnamesByResponsive;
@@ -191,11 +198,11 @@ module.exports = {
       // motion-safe/reduce are not present...
       // TODO Check if already present due to custom config overwiting the default `variantOrder`
       const stateVariants = [...mergedConfig.variantOrder, 'motion-safe', 'motion-reduce'];
-      const aIdxStr = `${getSpecificity(attrUtil.cleanClassname(a), responsiveVariants, true)}${getSpecificity(
+      const aIdxStr = `${getSpecificity(a, responsiveVariants, true)}${getSpecificity(
         a,
         themeVariants
       )}${getSpecificity(a, stateVariants)}`;
-      const bIdxStr = `${getSpecificity(attrUtil.cleanClassname(b), responsiveVariants, true)}${getSpecificity(
+      const bIdxStr = `${getSpecificity(b, responsiveVariants, true)}${getSpecificity(
         b,
         themeVariants
       )}${getSpecificity(b, stateVariants)}`;
@@ -222,7 +229,6 @@ module.exports = {
       let end = null;
       let prefix = '';
       let suffix = '';
-      let trim = false;
       if (arg === null) {
         originalClassNamesValue = astUtil.extractValueFromNode(node);
         const range = astUtil.extractRangeFromNode(node);
@@ -235,6 +241,8 @@ module.exports = {
         }
       } else {
         switch (arg.type) {
+          case 'Identifier':
+            return;
           case 'TemplateLiteral':
             arg.expressions.forEach((exp) => {
               sortNodeArgumentValue(node, exp);
@@ -261,13 +269,15 @@ module.exports = {
             });
             return;
           case 'Literal':
-            trim = true;
             originalClassNamesValue = arg.value;
             start = arg.range[0] + 1;
             end = arg.range[1] - 1;
             break;
           case 'TemplateElement':
             originalClassNamesValue = arg.value.raw;
+            if (originalClassNamesValue === '') {
+              return;
+            }
             start = arg.range[0];
             end = arg.range[1];
             // https://github.com/eslint/eslint/issues/13360
@@ -277,61 +287,66 @@ module.exports = {
             const txt = context.getSourceCode().getText(arg);
             prefix = astUtil.getTemplateElementPrefix(txt, originalClassNamesValue);
             suffix = astUtil.getTemplateElementSuffix(txt, originalClassNamesValue);
+            originalClassNamesValue = astUtil.getTemplateElementBody(txt, prefix, suffix);
             break;
         }
       }
 
-      let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, trim);
-      const isSingleLine = attrUtil.isSingleLine(originalClassNamesValue);
-      let before = null;
-      let after = null;
+      let { classNames, whitespaces, headSpace, tailSpace } =
+        astUtil.extractClassnamesFromValue(originalClassNamesValue);
 
-      if (!isSingleLine) {
-        const spacesOnly = /^(\s)*$/;
-        if (spacesOnly.test(classNames[0])) {
-          before = classNames.shift();
-        }
-        if (spacesOnly.test(classNames[classNames.length - 1])) {
-          after = classNames.pop();
-        }
-      }
-
-      if (removeDuplicates) {
-        classNames = removeDuplicatesFromArray(classNames);
-      }
       if (classNames.length <= 1) {
         // Don't run sorting for a single or empty className
         return;
       }
 
-      // Sorting
-      const mergedSorted = [];
-      const mergedExtras = [];
-      if (groupByResponsive) {
-        const respGroups = getResponsiveGroups(classNames);
-        respGroups.forEach((clsGroup) => {
-          const { sorted, extras } = getSortedGroups(clsGroup);
+      let orderedClassNames;
+      let validatedClassNamesValue = '';
+
+      if (officialSorting) {
+        orderedClassNames = order(classNames, contextFallback);
+        for (let i = 0; i < orderedClassNames.length; i++) {
+          const w = whitespaces[i] ?? '';
+          const cls = orderedClassNames[i];
+          validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`;
+          if (headSpace && tailSpace && i === orderedClassNames.length - 1) {
+            validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
+          }
+        }
+      } else {
+        if (removeDuplicates) {
+          removeDuplicatesFromClassnamesAndWhitespaces(classNames, whitespaces, headSpace, tailSpace);
+        }
+
+        // Sorting
+        const mergedSorted = [];
+        const mergedExtras = [];
+        if (groupByResponsive) {
+          const respGroups = getResponsiveGroups(classNames);
+          respGroups.forEach((clsGroup) => {
+            const { sorted, extras } = getSortedGroups(clsGroup);
+            mergedSorted.push(...sorted);
+            mergedExtras.push(...extras);
+          });
+        } else {
+          const { sorted, extras } = getSortedGroups(classNames);
           mergedSorted.push(...sorted);
           mergedExtras.push(...extras);
-        });
-      } else {
-        const { sorted, extras } = getSortedGroups(classNames);
-        mergedSorted.push(...sorted);
-        mergedExtras.push(...extras);
-      }
+        }
 
-      // Generates the validated/sorted attribute value
-      const flatted = mergedSorted.flat();
-      const union = prependCustom ? [...mergedExtras, ...flatted] : [...flatted, ...mergedExtras];
-      if (before !== null) {
-        union.unshift(before);
-      }
-      if (after !== null) {
-        union.push(after);
+        // Generates the validated/sorted attribute value
+        const flatted = mergedSorted.flat();
+        const union = prependCustom ? [...mergedExtras, ...flatted] : [...flatted, ...mergedExtras];
+        for (let i = 0; i < union.length; i++) {
+          const w = whitespaces[i] ?? '';
+          const cls = union[i];
+          validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`;
+          if (headSpace && tailSpace && i === union.length - 1) {
+            validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
+          }
+        }
       }
-      let validatedClassNamesValue = union.join(isSingleLine ? ' ' : '\n');
-      const originalPatched = isSingleLine ? originalClassNamesValue.trim() : originalClassNamesValue;
-      if (originalPatched !== validatedClassNamesValue) {
+      if (originalClassNamesValue !== validatedClassNamesValue) {
         validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
         context.report({
           node: node,
diff --git a/lib/rules/enforces-negative-arbitrary-values.js b/lib/rules/enforces-negative-arbitrary-values.js
index ecaeb11..e678b14 100644
--- a/lib/rules/enforces-negative-arbitrary-values.js
+++ b/lib/rules/enforces-negative-arbitrary-values.js
@@ -7,8 +7,8 @@
 const docsUrl = require('../util/docsUrl');
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
+const removeDuplicatesFromClassnamesAndWhitespaces = require('../util/removeDuplicatesFromClassnamesAndWhitespaces');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
 
@@ -74,11 +74,12 @@ module.exports = {
      */
     const parseForNegativeArbitraryClassNames = (node, arg = null) => {
       let originalClassNamesValue = null;
-      let trim = false;
       if (arg === null) {
         originalClassNamesValue = astUtil.extractValueFromNode(node);
       } else {
         switch (arg.type) {
+          case 'Identifier':
+            return;
           case 'TemplateLiteral':
             arg.expressions.forEach((exp) => {
               parseForNegativeArbitraryClassNames(node, exp);
@@ -105,21 +106,18 @@ module.exports = {
             });
             return;
           case 'Literal':
-            trim = true;
             originalClassNamesValue = arg.value;
             break;
           case 'TemplateElement':
             originalClassNamesValue = arg.value.raw;
-            // https://github.com/eslint/eslint/issues/13360
-            // The problem is that range computation includes the backticks (`test`)
-            // but value.raw does not include them, so there is a mismatch.
-            // start/end does not include the backticks, therefore it matches value.raw.
-            const txt = context.getSourceCode().getText(arg);
+            if (originalClassNamesValue === '') {
+              return;
+            }
             break;
         }
       }
 
-      let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, trim);
+      let { classNames } = astUtil.extractClassnamesFromValue(originalClassNamesValue);
 
       const detected = classNames.filter((cls) => {
         const suffix = groupUtil.getSuffix(cls, mergedConfig.separator);
diff --git a/lib/rules/enforces-shorthand.js b/lib/rules/enforces-shorthand.js
index 9af8456..c7d59b4 100644
--- a/lib/rules/enforces-shorthand.js
+++ b/lib/rules/enforces-shorthand.js
@@ -8,7 +8,6 @@ const docsUrl = require('../util/docsUrl');
 const defaultGroups = require('../config/groups').groups;
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
@@ -117,7 +116,6 @@ module.exports = {
       let end = null;
       let prefix = '';
       let suffix = '';
-      let trim = false;
       const troubles = [];
       if (arg === null) {
         originalClassNamesValue = astUtil.extractValueFromNode(node);
@@ -131,6 +129,8 @@ module.exports = {
         }
       } else {
         switch (arg.type) {
+          case 'Identifier':
+            return;
           case 'TemplateLiteral':
             arg.expressions.forEach((exp) => {
               parseForShorthandCandidates(node, exp);
@@ -157,13 +157,15 @@ module.exports = {
             });
             return;
           case 'Literal':
-            trim = true;
             originalClassNamesValue = arg.value;
             start = arg.range[0] + 1;
             end = arg.range[1] - 1;
             break;
           case 'TemplateElement':
             originalClassNamesValue = arg.value.raw;
+            if (originalClassNamesValue === '') {
+              return;
+            }
             start = arg.range[0];
             end = arg.range[1];
             // https://github.com/eslint/eslint/issues/13360
@@ -173,24 +175,13 @@ module.exports = {
             const txt = context.getSourceCode().getText(arg);
             prefix = astUtil.getTemplateElementPrefix(txt, originalClassNamesValue);
             suffix = astUtil.getTemplateElementSuffix(txt, originalClassNamesValue);
+            originalClassNamesValue = astUtil.getTemplateElementBody(txt, prefix, suffix);
             break;
         }
       }
 
-      let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, trim);
-      const isSingleLine = attrUtil.isSingleLine(originalClassNamesValue);
-      let before = null;
-      let after = null;
-
-      if (!isSingleLine) {
-        const spacesOnly = /^(\s)*$/;
-        if (spacesOnly.test(classNames[0])) {
-          before = classNames.shift();
-        }
-        if (spacesOnly.test(classNames[classNames.length - 1])) {
-          after = classNames.pop();
-        }
-      }
+      let { classNames, whitespaces, headSpace, tailSpace } =
+        astUtil.extractClassnamesFromValue(originalClassNamesValue);
 
       if (classNames.length <= 1) {
         // Don't run sorting for a single or empty className
@@ -331,21 +322,32 @@ module.exports = {
       // Generates the validated attribute value
       const union = validated.map((val) => val.leading + val.name + val.trailing);
 
-      if (before !== null) {
-        union.unshift(before);
-      }
-      if (after !== null) {
-        union.push(after);
+      let validatedClassNamesValue = '';
+
+      // Generates the validated attribute value
+      if (union.length === 1) {
+        validatedClassNamesValue += headSpace ? whitespaces[0] : '';
+        validatedClassNamesValue += union[0];
+        validatedClassNamesValue += tailSpace ? whitespaces[whitespaces.length - 1] : '';
+      } else {
+        for (let i = 0; i < union.length; i++) {
+          const isLast = i === union.length - 1;
+          const w = whitespaces[i] ?? '';
+          const cls = union[i];
+          validatedClassNamesValue += headSpace ? `${w}${cls}` : isLast ? `${cls}` : `${cls}${w}`;
+          if (headSpace && tailSpace && isLast) {
+            validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
+          }
+        }
       }
-      let validatedClassNamesValue = union.join(isSingleLine ? ' ' : '\n');
-      const originalPatched = isSingleLine ? originalClassNamesValue.trim() : originalClassNamesValue;
+
       troubles
         .filter((trouble) => {
           // Only valid issue if there are classes to replace
           return trouble[0].length;
         })
         .forEach((issue) => {
-          if (originalPatched !== validatedClassNamesValue) {
+          if (originalClassNamesValue !== validatedClassNamesValue) {
             validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
             context.report({
               node: node,
diff --git a/lib/rules/migration-from-tailwind-2.js b/lib/rules/migration-from-tailwind-2.js
index 6e40fe8..381de85 100644
--- a/lib/rules/migration-from-tailwind-2.js
+++ b/lib/rules/migration-from-tailwind-2.js
@@ -7,7 +7,6 @@
 const docsUrl = require('../util/docsUrl');
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
@@ -84,7 +83,6 @@ module.exports = {
       let end = null;
       let prefix = '';
       let suffix = '';
-      let trim = false;
       if (arg === null) {
         originalClassNamesValue = astUtil.extractValueFromNode(node);
         const range = astUtil.extractRangeFromNode(node);
@@ -97,6 +95,8 @@ module.exports = {
         }
       } else {
         switch (arg.type) {
+          case 'Identifier':
+            return;
           case 'TemplateLiteral':
             arg.expressions.forEach((exp) => {
               parseForObsoleteClassNames(node, exp);
@@ -123,13 +123,15 @@ module.exports = {
             });
             return;
           case 'Literal':
-            trim = true;
             originalClassNamesValue = arg.value;
             start = arg.range[0] + 1;
             end = arg.range[1] - 1;
             break;
           case 'TemplateElement':
             originalClassNamesValue = arg.value.raw;
+            if (originalClassNamesValue === '') {
+              return;
+            }
             start = arg.range[0];
             end = arg.range[1];
             // https://github.com/eslint/eslint/issues/13360
@@ -139,24 +141,13 @@ module.exports = {
             const txt = context.getSourceCode().getText(arg);
             prefix = astUtil.getTemplateElementPrefix(txt, originalClassNamesValue);
             suffix = astUtil.getTemplateElementSuffix(txt, originalClassNamesValue);
+            originalClassNamesValue = astUtil.getTemplateElementBody(txt, prefix, suffix);
             break;
         }
       }
 
-      let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, trim);
-      const isSingleLine = attrUtil.isSingleLine(originalClassNamesValue);
-      let before = null;
-      let after = null;
-
-      if (!isSingleLine) {
-        const spacesOnly = /^(\s)*$/;
-        if (spacesOnly.test(classNames[0])) {
-          before = classNames.shift();
-        }
-        if (spacesOnly.test(classNames[classNames.length - 1])) {
-          after = classNames.pop();
-        }
-      }
+      let { classNames, whitespaces, headSpace, tailSpace } =
+        astUtil.extractClassnamesFromValue(originalClassNamesValue);
 
       const notNeeded = [];
       const outdated = [];
@@ -200,7 +191,16 @@ module.exports = {
       });
 
       if (notNeeded.length) {
-        let validatedClassNamesValue = filtered.join(isSingleLine ? ' ' : '\n');
+        let validatedClassNamesValue = '';
+        for (let i = 0; i < filtered.length; i++) {
+          const isLast = i === filtered.length - 1;
+          const w = whitespaces[i] ?? '';
+          const cls = filtered[i];
+          validatedClassNamesValue += headSpace ? `${w}${cls}` : isLast ? `${cls}` : `${cls}${w}`;
+          if (headSpace && tailSpace && isLast) {
+            validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
+          }
+        }
         validatedClassNamesValue = prefix + validatedClassNamesValue + suffix;
         context.report({
           node,
@@ -215,7 +215,15 @@ module.exports = {
       }
 
       outdated.forEach((outdatedClass) => {
-        let validatedClassNamesValue = filtered.join(isSingleLine ? ' ' : '\n');
+        let validatedClassNamesValue = '';
+        for (let i = 0; i < filtered.length; i++) {
+          const w = whitespaces[i] ?? '';
+          const cls = filtered[i];
+          validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`;
+          if (headSpace && tailSpace && i === filtered.length - 1) {
+            validatedClassNamesValue += whitespaces[whitespaces.length - 1] ?? '';
+          }
+        }
         validatedClassNamesValue =
           prefix + validatedClassNamesValue.replace(outdatedClass[0], outdatedClass[1]) + suffix;
         context.report({
diff --git a/lib/rules/no-arbitrary-value.js b/lib/rules/no-arbitrary-value.js
index 1ca9791..6d9c516 100644
--- a/lib/rules/no-arbitrary-value.js
+++ b/lib/rules/no-arbitrary-value.js
@@ -7,7 +7,6 @@
 const docsUrl = require('../util/docsUrl');
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
@@ -74,11 +73,12 @@ module.exports = {
      */
     const parseForArbitraryValues = (node, arg = null) => {
       let originalClassNamesValue = null;
-      let trim = false;
       if (arg === null) {
         originalClassNamesValue = astUtil.extractValueFromNode(node);
       } else {
         switch (arg.type) {
+          case 'Identifier':
+            return;
           case 'TemplateLiteral':
             arg.expressions.forEach((exp) => {
               parseForArbitraryValues(node, exp);
@@ -105,16 +105,18 @@ module.exports = {
             });
             return;
           case 'Literal':
-            trim = true;
             originalClassNamesValue = arg.value;
             break;
           case 'TemplateElement':
             originalClassNamesValue = arg.value.raw;
+            if (originalClassNamesValue === '') {
+              return;
+            }
             break;
         }
       }
 
-      let classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, trim);
+      let { classNames } = astUtil.extractClassnamesFromValue(originalClassNamesValue);
       const forbidden = [];
       classNames.forEach((cls, idx) => {
         const parsed = groupUtil.parseClassname(cls, [], mergedConfig, idx);
diff --git a/lib/rules/no-contradicting-classname.js b/lib/rules/no-contradicting-classname.js
index 538642a..ed9b059 100644
--- a/lib/rules/no-contradicting-classname.js
+++ b/lib/rules/no-contradicting-classname.js
@@ -8,7 +8,6 @@ const docsUrl = require('../util/docsUrl');
 const defaultGroups = require('../config/groups').groups;
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
@@ -76,7 +75,6 @@ module.exports = {
      * @param {ASTNode} node
      */
     const parseForContradictingClassNames = (classNames, node) => {
-      classNames = attrUtil.sanitizeClassnames(classNames);
       // Init assets before sorting
       const sorted = groupUtil.initGroupSlots(groups);
 
diff --git a/lib/rules/no-custom-classname.js b/lib/rules/no-custom-classname.js
index ddd4314..0ab250f 100644
--- a/lib/rules/no-custom-classname.js
+++ b/lib/rules/no-custom-classname.js
@@ -8,7 +8,6 @@ const docsUrl = require('../util/docsUrl');
 const defaultGroups = require('../config/groups').groups;
 const customConfig = require('../util/customConfig');
 const astUtil = require('../util/ast');
-const attrUtil = require('../util/attr');
 const groupUtil = require('../util/groupMethods');
 const getOption = require('../util/settings');
 const parserUtil = require('../util/parser');
@@ -95,8 +94,6 @@ module.exports = {
      * @param {ASTNode} node
      */
     const parseForCustomClassNames = (classNames, node) => {
-      classNames = attrUtil.sanitizeClassnames(classNames);
-
       classNames.forEach((className) => {
         const idx = groupUtil.getGroupIndex(className, groups, mergedConfig.separator);
         if (idx >= 0) {
diff --git a/lib/util/ast.js b/lib/util/ast.js
index 7f0bd42..e7e16f3 100644
--- a/lib/util/ast.js
+++ b/lib/util/ast.js
@@ -8,7 +8,6 @@
 // /.../eslint-plugin-tailwindcss/node_modules/espree/espree.js
 // /.../eslint-plugin-tailwindcss/node_modules/@angular-eslint/template-parser/dist/index.js
 
-const attrUtil = require('./attr');
 const removeDuplicatesFromArray = require('./removeDuplicatesFromArray');
 
 /**
@@ -138,6 +137,27 @@ function extractValueFromNode(node) {
   }
 }
 
+function extractClassnamesFromValue(classStr) {
+  if (typeof classStr !== 'string') {
+    return { classNames: [], whitespaces: [], headSpace: false, tailSpace: false };
+  }
+  const separatorRegEx = /(\s+)/;
+  let parts = classStr.split(separatorRegEx);
+  if (parts[0] === '') {
+    parts.shift();
+  }
+  if (parts[parts.length - 1] === '') {
+    parts.pop();
+  }
+  let headSpace = separatorRegEx.test(parts[0]);
+  let tailSpace = separatorRegEx.test(parts[parts.length - 1]);
+  const isClass = (_, i) => (headSpace ? i % 2 !== 0 : i % 2 === 0);
+  const isNotClass = (_, i) => (headSpace ? i % 2 === 0 : i % 2 !== 0);
+  let classNames = parts.filter(isClass);
+  let whitespaces = parts.filter(isNotClass);
+  return { classNames: classNames, whitespaces: whitespaces, headSpace: headSpace, tailSpace: tailSpace };
+}
+
 /**
  * Inspect and parse an abstract syntax node and run a callback function
  *
@@ -153,7 +173,7 @@ function parseNodeRecursive(node, arg, cb, skipConditional = false, isolate = fa
   let classNames;
   if (arg === null) {
     originalClassNamesValue = extractValueFromNode(node);
-    classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, true);
+    ({ classNames } = extractClassnamesFromValue(originalClassNamesValue));
     classNames = removeDuplicatesFromArray(classNames);
     if (classNames.length === 0) {
       // Don't run for empty className
@@ -200,7 +220,7 @@ function parseNodeRecursive(node, arg, cb, skipConditional = false, isolate = fa
         originalClassNamesValue = arg.value.raw;
         break;
     }
-    classNames = attrUtil.getClassNamesFromAttribute(originalClassNamesValue, trim);
+    ({ classNames } = extractClassnamesFromValue(originalClassNamesValue));
     classNames = removeDuplicatesFromArray(classNames);
     if (classNames.length === 0) {
       // Don't run for empty className
@@ -220,21 +240,29 @@ function getTemplateElementPrefix(text, raw) {
 }
 
 function getTemplateElementSuffix(text, raw) {
-  const isSingleLine = attrUtil.isSingleLine(raw);
   if (text.indexOf(raw) === -1) {
     return '';
   }
-  const suffix = text.split(raw).pop();
-  const optionalSpace = isSingleLine && suffix === '${' ? ' ' : '';
-  return optionalSpace + suffix;
+  return text.split(raw).pop();
+}
+
+function getTemplateElementBody(text, prefix, suffix) {
+  let arr = text.split(prefix);
+  arr.shift();
+  let body = arr.join(prefix);
+  arr = body.split(suffix);
+  arr.pop();
+  return arr.join(suffix);
 }
 
 module.exports = {
   extractRangeFromNode,
   extractValueFromNode,
+  extractClassnamesFromValue,
   isValidJSXAttribute,
   isValidVueAttribute,
   parseNodeRecursive,
   getTemplateElementPrefix,
   getTemplateElementSuffix,
+  getTemplateElementBody,
 };
diff --git a/lib/util/attr.js b/lib/util/attr.js
deleted file mode 100644
index 6b93d8d..0000000
--- a/lib/util/attr.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Clean classname from spaces
- *
- * @param {string} str The attribute's value
- * @returns {String}
- */
-function cleanClassname(str) {
-  return str.replace(/\s{1,}/g, '');
-}
-
-/**
- * Confirms that attribute value is on a single line
- *
- * @param {string} str The attribute's value
- * @returns {Boolean}
- */
-function isSingleLine(str) {
-  let singleLine = true;
-  try {
-    singleLine = str.indexOf('\n') === -1;
-  } catch (err) {
-  } finally {
-    return singleLine;
-  }
-}
-
-/**
- * Convert a class[Name] attribute's values to an array of classnames
- *
- * @param {string} attrVal The attribute's value
- * @returns {Array}
- */
-function getClassNamesFromAttribute(attrVal, trim = true) {
-  let classNames = [];
-  const valid = typeof attrVal === 'string' && attrVal.length;
-  if (!valid) {
-    return classNames;
-  }
-  if (isSingleLine(attrVal)) {
-    // Fix multiple spaces + optional trim
-    attrVal = attrVal.replace(/\s{2,}/g, ' ');
-    if (trim) {
-      attrVal = attrVal.trim();
-    }
-    classNames = attrVal.split(' ').filter((cls) => cls.length);
-  } else {
-    classNames = attrVal.split('\n');
-  }
-  return classNames;
-}
-
-/**
- * Parse array of classnames, remove multiple spaces and empty strings
- * @param {Array} classNames
- * @returns {Array}
- */
-function sanitizeClassnames(classNames) {
-  // Remove multiple spaces
-  classNames = classNames.map((c) => c.replace(/\s{2,}/g, ' '));
-  // Remove empty candidates
-  classNames = classNames.map((c) => c.split(' '));
-  classNames = classNames.flat();
-  classNames = classNames.filter((c) => c.length);
-  return classNames;
-}
-
-module.exports = {
-  cleanClassname,
-  isSingleLine,
-  getClassNamesFromAttribute,
-  sanitizeClassnames,
-};
diff --git a/lib/util/prettier/order.js b/lib/util/prettier/order.js
new file mode 100644
index 0000000..60f84ec
--- /dev/null
+++ b/lib/util/prettier/order.js
@@ -0,0 +1,28 @@
+var generateRulesFallback = require('tailwindcss/lib/lib/generateRules').generateRules;
+
+function bigSign(bigIntValue) {
+  return (bigIntValue > 0n) - (bigIntValue < 0n);
+}
+
+function order(unordered, context) {
+  const classNamesWithOrder = [];
+  unordered.forEach((className) => {
+    const order =
+      generateRulesFallback(new Set([className]), context).sort(([a], [z]) => bigSign(z - a))[0]?.[0] ?? null;
+    classNamesWithOrder.push([className, order]);
+  });
+
+  const classes = classNamesWithOrder
+    .sort(([, a], [, z]) => {
+      if (a === z) return 0;
+      // if (a === null) return options.unknownClassPosition === 'start' ? -1 : 1
+      // if (z === null) return options.unknownClassPosition === 'start' ? 1 : -1
+      if (a === null) return -1;
+      if (z === null) return 1;
+      return bigSign(a - z);
+    })
+    .map(([className]) => className);
+  return classes;
+}
+
+module.exports = order;
diff --git a/lib/util/removeDuplicatesFromClassnamesAndWhitespaces.js b/lib/util/removeDuplicatesFromClassnamesAndWhitespaces.js
new file mode 100644
index 0000000..1b1ea51
--- /dev/null
+++ b/lib/util/removeDuplicatesFromClassnamesAndWhitespaces.js
@@ -0,0 +1,20 @@
+'use strict';
+
+function removeDuplicatesFromClassnamesAndWhitespaces(classNames, whitespaces, headSpace, tailSpace) {
+  const uniqueSet = new Set(classNames);
+  if (uniqueSet.size === classNames.length) {
+    return;
+  }
+  const offset = (!headSpace && !tailSpace) || tailSpace ? -1 : 0;
+  uniqueSet.forEach((cls) => {
+    let duplicatedInstances = classNames.filter((el) => el === cls).length - 1;
+    while (duplicatedInstances > 0) {
+      const idx = classNames.lastIndexOf(cls);
+      classNames.splice(idx, 1);
+      whitespaces.splice(idx + offset, 1);
+      duplicatedInstances--;
+    }
+  });
+}
+
+module.exports = removeDuplicatesFromClassnamesAndWhitespaces;
diff --git a/lib/util/settings.js b/lib/util/settings.js
index ce1e003..6475dc7 100644
--- a/lib/util/settings.js
+++ b/lib/util/settings.js
@@ -23,6 +23,8 @@ function getOption(context, name) {
       return true;
     case 'groups':
       return defaultGroups;
+    case 'officialSorting':
+      return false;
     case 'prependCustom':
       return false;
     case 'removeDuplicates':
diff --git a/package-lock.json b/package-lock.json
index c4c4880..428684e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-tailwindcss",
-  "version": "3.4.4",
+  "version": "3.5.0-beta.3",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -122,21 +122,20 @@
       }
     },
     "@tailwindcss/line-clamp": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.3.0.tgz",
-      "integrity": "sha512-ffDDclrqr3sy8cpChCozedDUAN8enxqAiWeH8d4dGQ2hcXlxf51+7KleveFi/n/TxEuRVApoL7hICeDOdYPKpg==",
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.3.1.tgz",
+      "integrity": "sha512-pNr0T8LAc3TUx/gxCfQZRe9NB2dPEo/cedPHzUGIPxqDMhgjwNm6jYxww4W5l0zAsAddxr+XfZcqttGiFDgrGg==",
       "dev": true
     },
     "@tailwindcss/typography": {
-      "version": "0.5.0",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.0.tgz",
-      "integrity": "sha512-1p/3C6C+JJziS/ghtG8ACYalbA2SyLJY27Pm33cVTlAoY6VQ7zfm2H64cPxUMBkVIlWXTtWHhZcZJPobMRmQAA==",
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.2.tgz",
+      "integrity": "sha512-coq8DBABRPFcVhVIk6IbKyyHUt7YTEC/C992tatFB+yEx5WGBQrCgsSFjxHUr8AWXphWckadVJbominEduYBqw==",
       "dev": true,
       "requires": {
         "lodash.castarray": "^4.4.0",
         "lodash.isplainobject": "^4.0.6",
-        "lodash.merge": "^4.6.2",
-        "lodash.uniq": "^4.5.0"
+        "lodash.merge": "^4.6.2"
       }
     },
     "@types/parse-json": {
@@ -1362,12 +1361,6 @@
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
       "dev": true
     },
-    "lodash.uniq": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
-      "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
-      "dev": true
-    },
     "log-symbols": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz",
diff --git a/package.json b/package.json
index 361f80c..1442ed9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-tailwindcss",
-  "version": "3.4.4",
+  "version": "3.5.0-beta.3",
   "description": "Rules enforcing best practices while using Tailwind CSS",
   "keywords": [
     "eslint",
@@ -32,8 +32,8 @@
     "@angular-eslint/template-parser": "^13.0.1",
     "@tailwindcss/aspect-ratio": "^0.4.0",
     "@tailwindcss/forms": "^0.4.0",
-    "@tailwindcss/line-clamp": "^0.3.0",
-    "@tailwindcss/typography": "^0.5.0",
+    "@tailwindcss/line-clamp": "^0.3.1",
+    "@tailwindcss/typography": "^0.5.2",
     "@typescript-eslint/parser": "^4.33.0",
     "autoprefixer": "^10.4.0",
     "eslint": "^7.1.0",


Test patch:
diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js
index 5895ba3..130c536 100644
--- a/tests/lib/rules/classnames-order.js
+++ b/tests/lib/rules/classnames-order.js
@@ -39,6 +39,28 @@ const generateErrors = (count) => {
 
 const errors = generateErrors(1);
 
+const sharedOptions = [
+  {
+    config: {
+      theme: {
+        extend: {
+          fontSize: { large: "20rem" },
+          colors: {
+            "deque-blue": "#243c5a",
+          },
+        },
+      },
+      plugins: [
+        require("@tailwindcss/typography"),
+        require("@tailwindcss/forms"),
+        require("@tailwindcss/aspect-ratio"),
+        require("@tailwindcss/line-clamp"),
+      ],
+    },
+    officialSorting: true,
+  },
+];
+
 ruleTester.run("classnames-order", rule, {
   valid: [
     {
@@ -178,6 +200,58 @@ ruleTester.run("classnames-order", rule, {
         },
       },
     },
+    {
+      code: `<div class="   flex  space-y-0.5   ">Extra spaces</div>`,
+    },
+    {
+      code: `<div class="container animate-spin first:flex">Valid using mode official</div>`,
+      options: [
+        {
+          officialSorting: true,
+        },
+      ],
+    },
+    {
+      code: `<div class="lorem-container lorem-animate-spin first_lorem-flex">Valid using mode official</div>`,
+      options: [
+        {
+          config: { prefix: "lorem-", separator: "_" },
+          officialSorting: true,
+        },
+      ],
+    },
+    {
+      code: `
+      <div class="custom group peer custom2 container sr-only pointer-events-auto visible sticky inset-0 inset-y-1 inset-x-2 top-3 right-4 bottom-5 left-6 isolate z-auto order-1 col-span-1 col-start-2 col-end-3 row-span-1 row-start-2 row-end-3 float-left clear-both m-0 my-px mx-1.5 mt-1 mr-2 mb-3 ml-4 box-border block aspect-auto h-0 max-h-fit min-h-full w-0 min-w-full max-w-fit flex-none flex-shrink shrink flex-grow grow basis-0 table-fixed border-collapse origin-bottom-right translate-x-px translate-y-2 rotate-90 skew-y-0 skew-x-2 scale-0 scale-y-50 scale-x-75 transform transform-gpu transform-none animate-spin cursor-text touch-auto select-none resize-none snap-x snap-mandatory snap-center snap-always scroll-m-0 scroll-my-px scroll-mx-1.5 scroll-mt-1 scroll-mr-2 scroll-mb-3 scroll-ml-4 scroll-p-0 scroll-py-px scroll-px-1.5 scroll-pt-1 scroll-pr-2 scroll-pb-3 scroll-pl-4 list-outside list-decimal appearance-none columns-1 break-before-avoid-page break-inside-avoid-column break-after-auto auto-cols-min grid-flow-col auto-rows-min grid-cols-1 grid-rows-1 flex-col flex-wrap place-content-between place-items-stretch content-between items-baseline justify-start justify-items-center gap-0 gap-x-1 gap-y-2 space-y-0 space-x-1 space-y-reverse space-x-reverse divide-x-2 divide-y-4 divide-y-reverse divide-x-reverse divide-dashed divide-black place-self-end self-baseline justify-self-stretch overflow-hidden overflow-x-auto overflow-y-scroll overscroll-auto overscroll-y-none overscroll-x-contain scroll-smooth overflow-ellipsis text-ellipsis whitespace-nowrap break-words rounded rounded-t-xl rounded-r-2xl rounded-b-sm rounded-l-none rounded-tl-xl rounded-tr-2xl rounded-br-sm rounded-bl-none border-0 border-y-2 border-x-2 border-t-4 border-b border-r-4 border-l border-solid border-black border-y-transparent border-x-slate-50 border-t-white border-r-transparent border-b-inherit border-l-current bg-current bg-opacity-50 bg-none from-current via-black to-orange-500 decoration-clone box-decoration-clone bg-auto bg-fixed bg-clip-padding bg-bottom bg-no-repeat bg-origin-border fill-white stroke-white stroke-2 object-contain object-bottom p-0 py-px px-1.5 pt-1 pr-2 pb-3 pl-4 text-left indent-px align-top font-sans text-lg font-semibold uppercase italic ordinal leading-4 tracking-normal text-current underline decoration-black decoration-double decoration-from-font underline-offset-auto subpixel-antialiased caret-black accent-black opacity-50 bg-blend-lighten mix-blend-darken shadow-lg shadow-white outline-dotted outline-0 outline-offset-1 outline-current ring-1 ring-inset ring-white ring-opacity-50 ring-offset-2 ring-offset-current blur brightness-50 contrast-100 drop-shadow-xl grayscale hue-rotate-90 invert saturate-100 sepia filter backdrop-blur-lg backdrop-brightness-110 backdrop-contrast-125 backdrop-grayscale backdrop-hue-rotate-90 backdrop-invert backdrop-opacity-30 backdrop-saturate-100 backdrop-sepia transition-all delay-150 duration-1000 ease-linear will-change-scroll content-none [--scroll-offset:56px]">no plugin</div>
+      `,
+      options: [
+        {
+          officialSorting: true,
+        },
+      ],
+    },
+    {
+      code: `
+      <div class="bg-deque-blue text-large flex h-9 w-9 items-center justify-center rounded-full border-4 border-solid border-blue-100 text-white">https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/109#issuecomment-1044625260 no config, so bg-deque-blue text-large goes at first position because custom</div>
+      `,
+      options: [
+        {
+          officialSorting: true,
+        },
+      ],
+    },
+    {
+      code: `
+      <div class="flex h-9 w-9 items-center justify-center rounded-full border-4 border-solid border-blue-100 bg-deque-blue text-large text-white">https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/109#issuecomment-1044625260</div>
+      `,
+      options: sharedOptions,
+    },
+    {
+      code: `
+      <div class="custom group peer custom2 aspect-auto container prose prose-slate aspect-w-16 aspect-h-9 sr-only pointer-events-auto visible sticky inset-0 inset-y-1 inset-x-2 top-3 right-4 bottom-5 left-6 isolate z-auto order-1 col-span-1 col-start-2 col-end-3 row-span-1 row-start-2 row-end-3 float-left clear-both m-0 my-px mx-1.5 mt-1 mr-2 mb-3 ml-4 box-border block h-0 max-h-fit min-h-full w-0 min-w-full max-w-fit flex-none flex-shrink shrink flex-grow grow basis-0 table-fixed border-collapse origin-bottom-right translate-x-px translate-y-2 rotate-90 skew-y-0 skew-x-2 scale-0 scale-y-50 scale-x-75 transform transform-gpu transform-none animate-spin cursor-text touch-auto select-none resize-none snap-x snap-mandatory snap-center snap-always scroll-m-0 scroll-my-px scroll-mx-1.5 scroll-mt-1 scroll-mr-2 scroll-mb-3 scroll-ml-4 scroll-p-0 scroll-py-px scroll-px-1.5 scroll-pt-1 scroll-pr-2 scroll-pb-3 scroll-pl-4 list-outside list-decimal appearance-none columns-1 break-before-avoid-page break-inside-avoid-column break-after-auto auto-cols-min grid-flow-col auto-rows-min grid-cols-1 grid-rows-1 flex-col flex-wrap place-content-between place-items-stretch content-between items-baseline justify-start justify-items-center gap-0 gap-x-1 gap-y-2 space-y-0 space-x-1 space-y-reverse space-x-reverse divide-x-2 divide-y-4 divide-y-reverse divide-x-reverse divide-dashed divide-black place-self-end self-baseline justify-self-stretch overflow-hidden overflow-x-auto overflow-y-scroll overscroll-auto overscroll-y-none overscroll-x-contain scroll-smooth overflow-ellipsis text-ellipsis whitespace-nowrap break-words rounded rounded-t-xl rounded-r-2xl rounded-b-sm rounded-l-none rounded-tl-xl rounded-tr-2xl rounded-br-sm rounded-bl-none border-0 border-y-2 border-x-2 border-t-4 border-b border-r-4 border-l border-solid border-black border-y-transparent border-x-slate-50 border-t-white border-r-transparent border-b-inherit border-l-current bg-current bg-opacity-50 bg-none from-current via-black to-orange-500 decoration-clone box-decoration-clone bg-auto bg-fixed bg-clip-padding bg-bottom bg-no-repeat bg-origin-border fill-white stroke-white stroke-2 object-contain object-bottom p-0 py-px px-1.5 pt-1 pr-2 pb-3 pl-4 text-left indent-px align-top font-sans text-lg font-semibold uppercase italic ordinal leading-4 tracking-normal text-current underline decoration-black decoration-double decoration-from-font underline-offset-auto subpixel-antialiased caret-black accent-black opacity-50 bg-blend-lighten mix-blend-darken shadow-lg shadow-white outline-dotted outline-0 outline-offset-1 outline-current ring-1 ring-inset ring-white ring-opacity-50 ring-offset-2 ring-offset-current blur brightness-50 contrast-100 drop-shadow-xl grayscale hue-rotate-90 invert saturate-100 sepia filter backdrop-blur-lg backdrop-brightness-110 backdrop-contrast-125 backdrop-grayscale backdrop-hue-rotate-90 backdrop-invert backdrop-opacity-30 backdrop-saturate-100 backdrop-sepia transition-all delay-150 duration-1000 ease-linear will-change-scroll content-none line-clamp-2 [--scroll-offset:56px]">kitchensink</div>
+      `,
+      options: sharedOptions,
+    },
   ],
   invalid: [
     {
@@ -185,11 +259,9 @@ ruleTester.run("classnames-order", rule, {
       export interface FakePropsInterface {
         readonly name?: string;
       }
-      
       function Fake({
         name = 'yolo'
       }: FakeProps) {
-      
         return (
           <>
             <h1 className={"absolute bottom-0 w-full flex flex-col"}>Welcome {name}</h1>
@@ -197,18 +269,15 @@ ruleTester.run("classnames-order", rule, {
           </>
         );
       }
-      
       export default Fake;
       `,
       output: `
       export interface FakePropsInterface {
         readonly name?: string;
       }
-      
       function Fake({
         name = 'yolo'
       }: FakeProps) {
-      
         return (
           <>
             <h1 className={"flex absolute bottom-0 flex-col w-full"}>Welcome {name}</h1>
@@ -216,7 +285,6 @@ ruleTester.run("classnames-order", rule, {
           </>
         );
       }
-      
       export default Fake;
       `,
       parser: require.resolve("@typescript-eslint/parser"),
@@ -227,11 +295,6 @@ ruleTester.run("classnames-order", rule, {
       output: `<div class="container w-12 sm:w-6">Classnames will be ordered</div>`,
       errors: errors,
     },
-    {
-      code: `<div class="   flex  space-y-0.5   ">Extra spaces</div>`,
-      output: `<div class="flex space-y-0.5">Extra spaces</div>`,
-      errors: errors,
-    },
     {
       code: `<div class="sm:py-5 p-4 sm:px-7 lg:p-8">Enhancing readability</div>`,
       output: `<div class="p-4 sm:py-5 sm:px-7 lg:p-8">Enhancing readability</div>`,
@@ -268,6 +331,11 @@ ruleTester.run("classnames-order", rule, {
       output: `<div class="bg-gradient-to-r from-green-400 focus:from-pink-500 to-blue-500 focus:to-yellow-500"></div>`,
       errors: errors,
     },
+    {
+      code: "ctl(`w-full p-10 ${some}`)",
+      output: "ctl(`p-10 w-full ${some}`)",
+      errors: errors,
+    },
     {
       code: "<div className={ctl(`w-full p-10 ${live && 'bg-white dark:bg-black'}`)}>Space trim issue with fix</div>",
       output: "<div className={ctl(`p-10 w-full ${live && 'bg-white dark:bg-black'}`)}>Space trim issue with fix</div>",
@@ -313,8 +381,45 @@ ruleTester.run("classnames-order", rule, {
       errors: errors,
     },
     {
-      code: `<div class="w-12  lg:w-6   w-12">Multiple spaces</div>`,
-      output: `<div class="w-12 lg:w-6">Multiple spaces</div>`,
+      code: `<div class="w-12  lg:w-6   w-12">Single line dups + no head/tail spaces</div>`,
+      output: `<div class="w-12  lg:w-6">Single line dups + no head/tail spaces</div>`,
+      errors: errors,
+    },
+    {
+      code: `<div class=" w-12  lg:w-6   w-12">Single dups line + head spaces</div>`,
+      output: `<div class=" w-12  lg:w-6">Single dups line + head spaces</div>`,
+      errors: errors,
+    },
+    {
+      code: `<div class="w-12  lg:w-6   w-12 ">Single line dups + tail spaces</div>`,
+      output: `<div class="w-12  lg:w-6 ">Single line dups + tail spaces</div>`,
+      errors: errors,
+    },
+    {
+      // Multiline + both head/tail spaces
+      code: `
+      ctl(\`
+        invalid
+        sm:w-6
+        container
+        invalid
+        flex
+        container
+        w-12
+        flex
+        container
+        lg:w-4
+        lg:w-4
+      \`);`,
+      output: `
+      ctl(\`
+        container
+        flex
+        w-12
+        sm:w-6
+        lg:w-4
+        invalid
+      \`);`,
       errors: errors,
     },
     {
@@ -702,5 +807,25 @@ ruleTester.run("classnames-order", rule, {
         },
       ],
     },
+    {
+      code: `<div class="first:flex animate-spin custom container">Using official sorting</div>`,
+      output: `<div class="custom container animate-spin first:flex">Using official sorting</div>`,
+      errors: errors,
+      options: [
+        {
+          officialSorting: true,
+        },
+      ],
+    },
+    {
+      code: `ctl(\`\${some} container animate-spin first:flex \${bool ? "flex-col flex" : ""}\`)`,
+      output: `ctl(\`\${some} container animate-spin first:flex \${bool ? "flex flex-col" : ""}\`)`,
+      errors: errors,
+      options: [
+        {
+          officialSorting: true,
+        },
+      ],
+    },
   ],
 });