diff --git a/README.md b/README.md index 07479a40..dd4ef0f5 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 0b7410dd..ffbfeb0e 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": |, "groupByResponsive": , "groups": Array, + "officialSorting": , "prependCustom": , "removeDuplicates": , "tags": Array, @@ -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 e5572637..039b1c3b 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 ecaeb11b..e678b147 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 9af8456e..c7d59b4a 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 6e40fe8c..381de85b 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 1ca97914..6d9c5166 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 538642a7..ed9b0597 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 ddd4314d..0ab250f6 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 7f0bd420..e7e16f35 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 6b93d8db..00000000 --- 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 00000000..60f84ec0 --- /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 00000000..1b1ea51f --- /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 ce1e0035..6475dc74 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 c4c48808..428684ec 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 361f80cd..1442ed93 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", diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js index 5895ba38..130c5366 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: `
Extra spaces
`, + }, + { + code: `
Valid using mode official
`, + options: [ + { + officialSorting: true, + }, + ], + }, + { + code: `
Valid using mode official
`, + options: [ + { + config: { prefix: "lorem-", separator: "_" }, + officialSorting: true, + }, + ], + }, + { + code: ` + + `, + options: [ + { + officialSorting: true, + }, + ], + }, + { + code: ` +
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
+ `, + options: [ + { + officialSorting: true, + }, + ], + }, + { + code: ` +
https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/109#issuecomment-1044625260
+ `, + options: sharedOptions, + }, + { + code: ` + + `, + options: sharedOptions, + }, ], invalid: [ { @@ -185,11 +259,9 @@ ruleTester.run("classnames-order", rule, { export interface FakePropsInterface { readonly name?: string; } - function Fake({ name = 'yolo' }: FakeProps) { - return ( <>

Welcome {name}

@@ -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 ( <>

Welcome {name}

@@ -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: `
Classnames will be ordered
`, errors: errors, }, - { - code: `
Extra spaces
`, - output: `
Extra spaces
`, - errors: errors, - }, { code: `
Enhancing readability
`, output: `
Enhancing readability
`, @@ -268,6 +331,11 @@ ruleTester.run("classnames-order", rule, { output: `
`, errors: errors, }, + { + code: "ctl(`w-full p-10 ${some}`)", + output: "ctl(`p-10 w-full ${some}`)", + errors: errors, + }, { code: "
Space trim issue with fix
", output: "
Space trim issue with fix
", @@ -313,8 +381,45 @@ ruleTester.run("classnames-order", rule, { errors: errors, }, { - code: `
Multiple spaces
`, - output: `
Multiple spaces
`, + code: `
Single line dups + no head/tail spaces
`, + output: `
Single line dups + no head/tail spaces
`, + errors: errors, + }, + { + code: `
Single dups line + head spaces
`, + output: `
Single dups line + head spaces
`, + errors: errors, + }, + { + code: `
Single line dups + tail spaces
`, + output: `
Single line dups + tail spaces
`, + 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: `
Using official sorting
`, + output: `
Using official sorting
`, + 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, + }, + ], + }, ], });