diff --git a/lib/config/groups.js b/lib/config/groups.js index d01057f..234c89d 100644 --- a/lib/config/groups.js +++ b/lib/config/groups.js @@ -507,6 +507,10 @@ module.exports.groups = [ type: 'Max-Height', members: 'max\\-h\\-(?${maxHeight})', }, + { + type: 'Size', + members: 'size\\-(?${size})', + }, ], }, { @@ -594,6 +598,10 @@ module.exports.groups = [ members: 'overflow\\-(ellipsis|clip)', deprecated: true, }, + { + type: 'Text Wrap', + members: 'text\\-(wrap|nowrap|balance|pretty)', + }, { type: 'Text Indent', members: '(indent\\-(?${textIndent})|\\-indent\\-(?${-textIndent}))', @@ -1341,6 +1349,10 @@ module.exports.groups = [ type: 'Screen Readers', members: '(not\\-)?sr\\-only', }, + { + type: 'Forced Color Adjust', + members: 'forced\\-color\\-adjust\\-(auto|none)', + }, ], }, { diff --git a/lib/rules/enforces-shorthand.js b/lib/rules/enforces-shorthand.js index 9e40ba3..1fcc366 100644 --- a/lib/rules/enforces-shorthand.js +++ b/lib/rules/enforces-shorthand.js @@ -75,18 +75,28 @@ module.exports = { // These are shorthand candidates that do not share the same parent type const complexEquivalences = [ - [["overflow-hidden", "text-ellipsis", "whitespace-nowrap"], "truncate"] - ] + { + needles: ['overflow-hidden', 'text-ellipsis', 'whitespace-nowrap'], + shorthand: 'truncate', + mode: 'exact', + }, + { + needles: ['w-', 'h-'], + shorthand: 'size-', + mode: 'value', + }, + ]; // Init assets const targetProperties = { Layout: ['Overflow', 'Overscroll Behavior', 'Top / Right / Bottom / Left'], 'Flexbox & Grid': ['Gap'], Spacing: ['Padding', 'Margin'], + Sizing: ['Width', 'Height'], Borders: ['Border Radius', 'Border Width', 'Border Color'], Tables: ['Border Spacing'], Transforms: ['Scale'], - Typography: ['Text Overflow', 'Whitespace'] + Typography: ['Text Overflow', 'Whitespace'], }; // We don't want to affect other rules by object reference @@ -219,53 +229,79 @@ module.exports = { const validated = []; // Handle sets of classnames with different parent types - let remaining = parsed - for (const [inputSet, outputClassname] of complexEquivalences) { + let remaining = parsed; + for (const { needles: inputSet, shorthand: outputClassname, mode } of complexEquivalences) { if (remaining.length < inputSet.length) { - continue - } - - const parsedElementsInInputSet = remaining.filter(remainingClass => inputSet.some(inputClass => remainingClass.name.includes(inputClass))) - - // Make sure all required classes for the shorthand are present - if (parsedElementsInInputSet.length !== inputSet.length) { - continue + continue; } - // Make sure the classes share all the same variants - if (new Set(parsedElementsInInputSet.map(p => p.variants)).size !== 1) { - continue - } - - // Make sure the classes share all the same importance - if (new Set(parsedElementsInInputSet.map(p => p.important)).size !== 1) { - continue - } + // Matching classes + const parsedElementsInInputSet = remaining.filter((remainingClass) => { + if (mode === 'exact') { + // Test if the name contains the target class, eg. 'text-ellipsis' inside 'md:text-ellipsis'... + return inputSet.some((inputClass) => remainingClass.name.includes(inputClass)); + } + // Test if the body of the class matches, eg. 'h-' inside 'h-10' + if (mode === 'value') { + return inputSet.some((inputClassPattern) => inputClassPattern === remainingClass.body); + } + }); - const index = parsedElementsInInputSet[0].index - const variants = parsedElementsInInputSet[0].variants - const important = parsedElementsInInputSet[0].important ? "!" : "" + const variantGroups = new Map(); + parsedElementsInInputSet.forEach((o) => { + const val = mode === 'value' ? o.value : ''; + const v = `${o.variants}${o.important ? '!' : ''}${val}`; + if (!variantGroups.has(v)) { + variantGroups.set( + v, + parsedElementsInInputSet.filter( + (c) => c.variants === o.variants && c.important === o.important && (val === '' || c.value === val) + ) + ); + } + }); + const validKeys = new Set(); + variantGroups.forEach((classes, key) => { + let skip = false; + // Make sure all required classes for the shorthand are present + if (classes.length < inputSet.length) { + skip = true; + } + // Make sure the classes share all the single/shared/same value + if (mode === 'value' && new Set(classes.map((p) => p.value)).size !== 1) { + skip = true; + } + if (!skip) { + validKeys.add(key); + } + }); + validKeys.forEach((k) => { + const candidates = variantGroups.get(k); + const index = candidates[0].index; + const variants = candidates[0].variants; + const important = candidates[0].important ? '!' : ''; + const classValue = mode === 'value' ? candidates[0].value : ''; - const patchedClassname = `${variants}${important}${mergedConfig.prefix}${outputClassname}` - troubles.push([parsedElementsInInputSet.map((c) => `${c.name}`), patchedClassname]); + const patchedClassname = `${variants}${important}${mergedConfig.prefix}${outputClassname}${classValue}`; + troubles.push([candidates.map((c) => `${c.name}`), patchedClassname]); - const validatedClassname = groupUtil.parseClassname(patchedClassname, targetGroups, mergedConfig, index) - validated.push(validatedClassname); + const validatedClassname = groupUtil.parseClassname(patchedClassname, targetGroups, mergedConfig, index); + validated.push(validatedClassname); - remaining = remaining.filter(p => !parsedElementsInInputSet.includes(p)) + remaining = remaining.filter((p) => !candidates.includes(p)); + }); } // Handle sets of classnames with the same parent type - // Each group parentType const checkedGroups = []; - remaining.forEach((classname) => { + remaining.forEach((classname, idx, arr) => { // Valid candidate if (classname.parentType === '') { validated.push(classname); } else if (!checkedGroups.includes(classname.parentType)) { checkedGroups.push(classname.parentType); - const sameType = parsed.filter((cls) => cls.parentType === classname.parentType); + const sameType = remaining.filter((cls) => cls.parentType === classname.parentType); // Comparing same parentType classnames const checkedVariantsValue = []; sameType.forEach((cls) => { @@ -404,27 +440,22 @@ module.exports = { } } - troubles - .filter((trouble) => { - // Only valid issue if there are classes to replace - return trouble[0].length; - }) - .forEach((issue) => { - if (originalClassNamesValue !== validatedClassNamesValue) { - validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; - context.report({ - node: node, - messageId: 'shorthandCandidateDetected', - data: { - classnames: issue[0].join(', '), - shorthand: issue[1], - }, - fix: function (fixer) { - return fixer.replaceTextRange([start, end], validatedClassNamesValue); - }, - }); - } - }); + troubles.forEach((issue) => { + if (originalClassNamesValue !== validatedClassNamesValue) { + validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; + context.report({ + node: node, + messageId: 'shorthandCandidateDetected', + data: { + classnames: issue[0].join(', '), + shorthand: issue[1], + }, + fix: function (fixer) { + return fixer.replaceTextRange([start, end], validatedClassNamesValue); + }, + }); + } + }); }; //---------------------------------------------------------------------- diff --git a/lib/util/groupMethods.js b/lib/util/groupMethods.js index 4ee1558..7b59571 100644 --- a/lib/util/groupMethods.js +++ b/lib/util/groupMethods.js @@ -160,6 +160,7 @@ function generateOptions(propName, keys, config, isNegative = false) { case 'height': case 'lineHeight': case 'maxHeight': + case 'size': case 'maxWidth': case 'minHeight': case 'minWidth': diff --git a/package-lock.json b/package-lock.json index 19669da..8e7aa1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eslint-plugin-tailwindcss", - "version": "3.13.1", + "version": "3.14.0-beta.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "eslint-plugin-tailwindcss", - "version": "3.13.1", + "version": "3.14.0-beta.1", "license": "MIT", "dependencies": { "fast-glob": "^3.2.5", @@ -23,7 +23,7 @@ "daisyui": "^2.6.4", "eslint": "^7.1.0", "mocha": "^10.2.0", - "tailwindcss": "^3.3.2", + "tailwindcss": "^3.4.0", "typescript": "4.3.5", "vue-eslint-parser": "^9.1.0" }, @@ -31,7 +31,7 @@ "node": ">=12.13.0" }, "peerDependencies": { - "tailwindcss": "^3.3.2" + "tailwindcss": "^3.4.0" } }, "node_modules/@alloc/quick-lru": { @@ -1211,9 +1211,9 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -1627,9 +1627,9 @@ "dev": true }, "node_modules/jiti": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", - "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", "dev": true, "bin": { "jiti": "bin/jiti.js" @@ -2777,9 +2777,9 @@ "dev": true }, "node_modules/tailwindcss": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", - "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", + "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -2787,10 +2787,10 @@ "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.2.12", + "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.18.2", + "jiti": "^1.19.1", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -2802,7 +2802,6 @@ "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, @@ -4085,9 +4084,9 @@ "dev": true }, "fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -4389,9 +4388,9 @@ "dev": true }, "jiti": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", - "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", "dev": true }, "js-tokens": { @@ -5181,9 +5180,9 @@ } }, "tailwindcss": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", - "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", + "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", "dev": true, "requires": { "@alloc/quick-lru": "^5.2.0", @@ -5191,10 +5190,10 @@ "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.2.12", + "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.18.2", + "jiti": "^1.19.1", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -5206,7 +5205,6 @@ "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, diff --git a/package.json b/package.json index 53bb1c6..eb99550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-tailwindcss", - "version": "3.13.1", + "version": "3.14.0-beta.1", "description": "Rules enforcing best practices while using Tailwind CSS", "keywords": [ "eslint", @@ -18,13 +18,14 @@ "bugs": "https://github.com/francoismassart/eslint-plugin-tailwindcss/issues", "main": "lib/index.js", "scripts": { + "wip": "mocha tests/lib/rules/no-arbitrary-value.js", "test": "mocha tests --recursive" }, "files": [ "lib" ], "peerDependencies": { - "tailwindcss": "^3.3.2" + "tailwindcss": "^3.4.0" }, "dependencies": { "fast-glob": "^3.2.5", @@ -41,7 +42,7 @@ "daisyui": "^2.6.4", "eslint": "^7.1.0", "mocha": "^10.2.0", - "tailwindcss": "^3.3.2", + "tailwindcss": "^3.4.0", "typescript": "4.3.5", "vue-eslint-parser": "^9.1.0" }, diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js index 894367c..b797f01 100644 --- a/tests/lib/rules/classnames-order.js +++ b/tests/lib/rules/classnames-order.js @@ -84,7 +84,7 @@ ruleTester.run("classnames-order", rule, { ], }, { - code: "
ctl + exp
", + code: "
ctl + exp
", }, { code: "
ctl + var
", @@ -243,7 +243,7 @@ ruleTester.run("classnames-order", rule, { { // Kitchensink code: ` - + `, options: sharedOptions, }, diff --git a/tests/lib/rules/enforces-shorthand.js b/tests/lib/rules/enforces-shorthand.js index f21411f..d14368b 100644 --- a/tests/lib/rules/enforces-shorthand.js +++ b/tests/lib/rules/enforces-shorthand.js @@ -393,8 +393,7 @@ ruleTester.run("shorthands", rule, { output: `
Multilines
`, - errors: [generateError(["py-8", "px-8"], "p-8")], + errors: [generateError(["w-48", "h-48"], "size-48"), generateError(["py-8", "px-8"], "p-8")], }, { code: `classnames(['py-8 px-8 w-48 h-48 text-white'])`, - output: `classnames(['p-8 w-48 h-48 text-white'])`, - errors: [generateError(["py-8", "px-8"], "p-8")], + output: `classnames(['p-8 size-48 text-white'])`, + errors: [generateError(["w-48", "h-48"], "size-48"), generateError(["py-8", "px-8"], "p-8")], }, { - code: `classnames({'py-8 px-8 w-48 h-48 text-white': true})`, - output: `classnames({'p-8 w-48 h-48 text-white': true})`, + code: `classnames({'py-8 px-8 text-white': true})`, + output: `classnames({'p-8 text-white': true})`, errors: [generateError(["py-8", "px-8"], "p-8")], }, { - code: `classnames({'!py-8 !px-8 w-48 h-48 text-white': true})`, - output: `classnames({'!p-8 w-48 h-48 text-white': true})`, + code: `classnames({'!py-8 !px-8 text-white': true})`, + output: `classnames({'!p-8 text-white': true})`, errors: [generateError(["!py-8", "!px-8"], "!p-8")], }, { @@ -652,7 +651,9 @@ ruleTester.run("shorthands", rule, { Possible shorthand when using truncate with hover `, - errors: [generateError(["hover:overflow-hidden", "hover:text-ellipsis", "hover:whitespace-nowrap"], "hover:truncate")], + errors: [ + generateError(["hover:overflow-hidden", "hover:text-ellipsis", "hover:whitespace-nowrap"], "hover:truncate"), + ], }, { code: ` @@ -665,7 +666,12 @@ ruleTester.run("shorthands", rule, { Possible shorthand when using truncate with hover, breakpoint, important and prefix `, - errors: [generateError(["hover:sm:!tw-overflow-hidden", "hover:sm:!tw-text-ellipsis", "hover:sm:!tw-whitespace-nowrap"], "hover:sm:!tw-truncate")], + errors: [ + generateError( + ["hover:sm:!tw-overflow-hidden", "hover:sm:!tw-text-ellipsis", "hover:sm:!tw-whitespace-nowrap"], + "hover:sm:!tw-truncate" + ), + ], options: [ { config: { prefix: "tw-" }, @@ -685,5 +691,15 @@ ruleTester.run("shorthands", rule, { `, errors: [generateError(["overflow-hidden", "text-ellipsis", "whitespace-nowrap"], "truncate")], }, + { + code: `
New size-* utilities
`, + output: `
New size-* utilities
`, + errors: [generateError(["h-10", "w-10"], "size-10")], + }, + { + code: `
New size-* utilities
`, + output: `
New size-* utilities
`, + errors: [generateError(["md:h-5", "md:w-5"], "md:size-5")], + }, ], }); diff --git a/tests/lib/rules/no-arbitrary-value.js b/tests/lib/rules/no-arbitrary-value.js index bd7f7ce..f564813 100644 --- a/tests/lib/rules/no-arbitrary-value.js +++ b/tests/lib/rules/no-arbitrary-value.js @@ -156,5 +156,9 @@ ruleTester.run("no-arbitrary-value", rule, { filename: "test.vue", parser: require.resolve("vue-eslint-parser"), }, + { + code: `
Dynamic viewport units
`, + errors: generateErrors(["min-h-[75dvh]"]), + }, ], }); diff --git a/tests/lib/rules/no-contradicting-classname.js b/tests/lib/rules/no-contradicting-classname.js index 9787027..89adda1 100644 --- a/tests/lib/rules/no-contradicting-classname.js +++ b/tests/lib/rules/no-contradicting-classname.js @@ -295,9 +295,17 @@ ruleTester.run("no-contradicting-classname", rule, { code: `
Issue #186
`, }, + { + code: `
`, + }, { code: ` -
`, +
+
Dynamic viewport units
+
Dynamic viewport units
+
Dynamic viewport units
+
+ `, }, ], @@ -709,6 +717,22 @@ ruleTester.run("no-contradicting-classname", rule, { `, errors: generateErrors(["sm:bg-[url('foo.jpg')] sm:bg-[url('bar.jpg')]"]), }, + { + code: `
Dynamic viewport units
`, + errors: generateErrors(["h-svh h-lvh h-dvh", "min-h-svh min-h-lvh min-h-dvh", "max-h-svh max-h-lvh max-h-dvh"]), + }, + { + code: `
Dynamic viewport units
`, + errors: generateErrors(["size-5 size-10"]), + }, + { + code: `

Balanced headlines with text-wrap utilities

`, + errors: generateErrors(["text-wrap text-nowrap"]), + }, + { + code: `
Subgrid support
`, + errors: generateErrors(["grid-rows-4 grid-rows-subgrid"]), + }, // { // code: ` //
diff --git a/tests/lib/rules/no-custom-classname.js b/tests/lib/rules/no-custom-classname.js index d4bcda5..0840cb0 100644 --- a/tests/lib/rules/no-custom-classname.js +++ b/tests/lib/rules/no-custom-classname.js @@ -993,6 +993,76 @@ ruleTester.run("no-custom-classname", rule, { }}>Spread inside classname object
`, }, + { + code: ` +
+
Dynamic viewport units
+
Dynamic viewport units
+
Dynamic viewport units
+
+ `, + }, + { + code: ` + + `, + }, + { + code: ` +
    +
  • Sales
  • +
  • Marketing
  • +
  • SEO
  • +
+ `, + }, + { + code: ``, + }, + { + code: `

Balanced headlines with text-wrap utilities

`, + }, + { + code: ` +
+
01
+
05
+
+
06
+
+
`, + }, + { + code: ` +
+
01
+
05
+
+
06
+
+
07
+
10
+
`, + }, + { + code: `
Extended min-width, max-width, and min-height scales
`, + }, + { + code: `
Extended opacity scale
`, + }, + { + code: `
Extended grid-rows-* scale
`, + }, + { + code: ` +
+ +
`, + }, + { + code: ` +

New forced-color-adjust utilities

`, + }, ], invalid: [ @@ -1414,5 +1484,9 @@ ruleTester.run("no-custom-classname", rule, { code: `
Full-width space before and after classes
`, errors: generateErrors("\u3000px-1 py-2\u3000"), }, + { + code: `
Subgrid support
`, + errors: generateErrors("grid-rows-supagrid"), + }, ], });