diff --git a/README.md b/README.md index 58efd46f..a9a1b37b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ While you can use the official plugin [`prettier-plugin-tailwindcss`](https://ww Learn more about each supported rules by reading their documentation: - [`classnames-order`](docs/rules/classnames-order.md): order classnames for consistency and it makes merge conflict a bit easier to resolve +- [`enforces-arbitrary-value-syntax`](docs/rules/enforces-arbitrary-value-syntax.md): enforce correct arbitrary value syntax using square brackets (e.g. `p-10px` should be `p-[10px]`) - [`enforces-negative-arbitrary-values`](docs/rules/enforces-negative-arbitrary-values.md): make sure to use negative arbitrary values classname without the negative classname e.g. `-top-[5px]` should become `top-[-5px]` - [`enforces-shorthand`](docs/rules/enforces-shorthand.md): merge multiple classnames into shorthand if possible e.g. `mx-5 my-5` should become `m-5` - [`migration-from-tailwind-2`](docs/rules/migration-from-tailwind-2.md) for easy upgrade from Tailwind CSS `v2` to `v3`. diff --git a/docs/rules/enforces-arbitrary-value-syntax.md b/docs/rules/enforces-arbitrary-value-syntax.md new file mode 100644 index 00000000..647fcd8f --- /dev/null +++ b/docs/rules/enforces-arbitrary-value-syntax.md @@ -0,0 +1,84 @@ +# Enforces correct Tailwind CSS arbitrary value syntax (enforces-arbitrary-value-syntax) + +This rule enforces the correct Tailwind CSS arbitrary value syntax, which is using square brackets `[]` to wrap arbitrary values. + +## Rule Details + +In Tailwind CSS, when you need to use a custom value that is not in the preset, you should use the arbitrary value syntax `prefix-[value]` instead of directly appending the value after the prefix. + +### Incorrect Examples + +```html +
+ These class names use the incorrect syntax +
+``` + +```js +classnames("p-10px", "mx-15px", "leading-20px", "text-60px", "w-800px"); +``` + +### Correct Examples + +```html +
+ These class names use the correct syntax +
+``` + +```js +classnames( + "p-[10px]", + "mx-[15px]", + "leading-[20px]", + "text-[60px]", + "w-[800px]" +); +``` + +## Options + +```js +... +"tailwindcss/enforces-arbitrary-value-syntax": [, { + "callees": Array, + "config": |, + "skipClassAttribute": , + "tags": Array, +}] +... +``` + +### `callees` (default: `["classnames", "clsx", "ctl", "cva", "tv"]`) + +If you use tool libraries like [@netlify/classnames-template-literals](https://github.com/netlify/classnames-template-literals), you can add their names to the list to ensure that the rule can parse them correctly. + +### `ignoredKeys` (default: `["compoundVariants", "defaultVariants"]`) + +When using libraries like `cva`, some object keys are not used to contain class names. +You can specify which keys will not be parsed by the plugin through this setting. +For example, `cva` has `compoundVariants` and `defaultVariants`. +Note: Since `compoundVariants` can contain class names in its `class` attribute, you can also use callee to ensure that this inner section is parsed while its parent is ignored. + +### `config` (default: generated by `tailwindcss/lib/lib/load-config`) + +By default, the plugin will try to load the file returned by the official `loadConfig()` tool. + +This allows the plugin to use your customized `colors`, `spacing`, `screens`, etc. + +You can provide another path or file name for the Tailwind CSS configuration file, such as `"config/tailwind.js"`. + +If the external file cannot be loaded (e.g. the path is incorrect or the file has been deleted), an empty object `{}` will be used. + +You can also directly inject a pure `object` configuration, such as `{ prefix: "tw-", theme: { ... } }`. + +Finally, the plugin will [merge the provided configuration](https://tailwindcss.com/docs/configuration#referencing-in-java-script) with [Tailwind CSS's default configuration](https://github.com/tailwindlabs/tailwindcss/blob/master/stubs/defaultConfig.stub.js). + +### `skipClassAttribute` (default: `false`) + +Set `skipClassAttribute` to `true` if you only want to check class names inside functions in `callees`. +This will avoid checking `class` and `className` attributes, but will still check `callees` inside these attributes. + +### `tags` (default: `[]`) + +Optional, if you use tag templates, you should provide them in this array. diff --git a/lib/config/rules.js b/lib/config/rules.js index 2399d016..9c922fe1 100644 --- a/lib/config/rules.js +++ b/lib/config/rules.js @@ -5,6 +5,7 @@ module.exports = { 'tailwindcss/classnames-order': 'warn', + 'tailwindcss/enforces-arbitrary-value-syntax': 'warn', 'tailwindcss/enforces-negative-arbitrary-values': 'warn', 'tailwindcss/enforces-shorthand': 'warn', 'tailwindcss/migration-from-tailwind-2': 'warn', diff --git a/lib/index.js b/lib/index.js index 53824e76..b6e630de 100644 --- a/lib/index.js +++ b/lib/index.js @@ -13,6 +13,7 @@ var base = __dirname + '/rules/'; module.exports = { rules: { 'classnames-order': require(base + 'classnames-order'), + 'enforces-arbitrary-value-syntax': require(base + 'enforces-arbitrary-value-syntax'), 'enforces-negative-arbitrary-values': require(base + 'enforces-negative-arbitrary-values'), 'enforces-shorthand': require(base + 'enforces-shorthand'), 'migration-from-tailwind-2': require(base + 'migration-from-tailwind-2'), diff --git a/lib/rules/enforces-arbitrary-value-syntax.js b/lib/rules/enforces-arbitrary-value-syntax.js new file mode 100644 index 00000000..16d61480 --- /dev/null +++ b/lib/rules/enforces-arbitrary-value-syntax.js @@ -0,0 +1,330 @@ +/** + * @fileoverview Enforces correct arbitrary value syntax (using square brackets) + * @author Sweet + */ +'use strict'; + +const docsUrl = require('../util/docsUrl'); +const customConfig = require('../util/customConfig'); +const astUtil = require('../util/ast'); +const groupUtil = require('../util/groupMethods'); +const getOption = require('../util/settings'); +const parserUtil = require('../util/parser'); +const createContextFallback = require('tailwindcss/lib/lib/setupContextUtils').createContext; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +// Predefine message for use in context.report conditional. +// messageId will still be usable in tests. +const INVALID_ARBITRARY_VALUE_MSG = `Classname '{{classname}}' uses invalid arbitrary value syntax, should be '{{suggestion}}'`; + +// Regular expression to match class names with incorrect value usage +// Modified to only match values with units, avoiding matching pure numbers and color values +const INVALID_ARBITRARY_VALUE_REGEX = /^([a-z][\w-]*?)-([-]?\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%|s|ms|deg|turn))$/i; + +// Regular expression to exclude color classes (e.g. bg-blue-500, text-red-600, etc.) +const COLOR_CLASSNAME_REGEX = /-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+$/i; + +// List of supported CSS units +const CSS_UNITS = ['px', 'rem', 'em', 'vh', 'vw', 'vmin', 'vmax', 's', 'ms', 'deg', 'turn', 'rad', 'fr', 'ch', 'ex', 'rpx']; + +// Regular expression to match values with units +const CSS_UNIT_REGEX = new RegExp(`^([a-z][\\w-]*?)-([-]?\\d+(?:\\.\\d+)?(?:${CSS_UNITS.join('|')}))$`, 'i'); + +// Regular expression to exclude classes with percentage units +const PERCENTAGE_REGEX = /^([a-z][\w-]*?)-\d+(?:\.\d+)?%$/i; + +const contextFallbackCache = new WeakMap(); + +module.exports = { + meta: { + docs: { + description: 'Enforces correct arbitrary value syntax (using square brackets)', + category: 'Best Practices', + recommended: false, + url: docsUrl('enforces-arbitrary-value-syntax'), + }, + messages: { + invalidArbitraryValueSyntax: INVALID_ARBITRARY_VALUE_MSG, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + callees: { + type: 'array', + items: { type: 'string', minLength: 0 }, + uniqueItems: true, + }, + ignoredKeys: { + type: 'array', + items: { type: 'string', minLength: 0 }, + uniqueItems: true, + }, + config: { + // returned from `loadConfig()` utility + type: ['string', 'object'], + }, + tags: { + type: 'array', + items: { type: 'string', minLength: 0 }, + uniqueItems: true, + }, + }, + }, + ], + }, + + create: function (context) { + const callees = getOption(context, 'callees'); + const ignoredKeys = getOption(context, 'ignoredKeys'); + const skipClassAttribute = getOption(context, 'skipClassAttribute'); + const tags = getOption(context, 'tags'); + const twConfig = getOption(context, 'config'); + const classRegex = getOption(context, 'classRegex'); + + const mergedConfig = customConfig.resolve(twConfig); + const contextFallback = // Set the created contextFallback in the cache if it does not exist yet. + ( + contextFallbackCache.has(mergedConfig) + ? contextFallbackCache + : contextFallbackCache.set(mergedConfig, createContextFallback(mergedConfig)) + ).get(mergedConfig); + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + /** + * Check if a class name uses incorrect arbitrary value syntax + * @param {string} className The class name to check + * @returns {object|null} An object containing the correction suggestion, or null if no correction is needed + */ + const checkArbitraryValueSyntax = (className) => { + // Skip if the class name matches the color class pattern (e.g. bg-blue-500) + if (COLOR_CLASSNAME_REGEX.test(className)) { + return null; + } + + // Skip if the class name contains a percentage unit + if (PERCENTAGE_REGEX.test(className)) { + return null; + } + + // Check if it matches a value with a unit + const match = className.match(CSS_UNIT_REGEX); + if (match) { + const [, prefix, value] = match; + // Build the corrected class name + const correctedClassName = `${prefix}-[${value}]`; + return { + original: className, + suggestion: correctedClassName + }; + } + return null; + }; + + /** + * Parse class names and report any syntax errors found + * @param {Array} classNames Array of class names + * @param {ASTNode} node AST node + * @param {Function} fixerFn Fix function + */ + const parseForInvalidArbitraryValueSyntax = (classNames, node, fixerFn) => { + // Collect all class names that need to be fixed + const invalidClassnames = classNames + .map(className => { + const result = checkArbitraryValueSyntax(className); + return result ? { + original: result.original, + suggestion: result.suggestion + } : null; + }) + .filter(result => result !== null); + + // If there are class names that need to be fixed, report errors + if (invalidClassnames.length > 0) { + invalidClassnames.forEach(result => { + context.report({ + node, + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: result.original, + suggestion: result.suggestion, + }, + fix: fixerFn ? (fixer) => fixerFn(fixer, invalidClassnames) : null, + }); + }); + } + }; + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + const attributeVisitor = function (node) { + if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) { + return; + } + + // Handle text attribute + if (node.type === 'TextAttribute' && node.name === 'class') { + const originalValue = node.value; + if (typeof originalValue === 'string') { + const { classNames } = astUtil.extractClassnamesFromValue(originalValue); + + const fixerFn = (fixer, invalidClassnames) => { + let updatedValue = originalValue; + + // Replace all invalid class names + invalidClassnames.forEach(item => { + const classNameRegex = new RegExp(`\\b${item.original}\\b`, 'g'); + updatedValue = updatedValue.replace(classNameRegex, item.suggestion); + }); + + return fixer.replaceText(node, `class="${updatedValue}"`); + }; + + parseForInvalidArbitraryValueSyntax(classNames, node, fixerFn); + } + } else if (astUtil.isLiteralAttributeValue(node)) { + const originalClassNamesValue = astUtil.extractValueFromNode(node); + + if (node.type === 'JSXAttribute') { + let start, end; + + if (node.value.type === 'Literal') { + start = node.value.range[0] + 1; + end = node.value.range[1] - 1; + } else if (node.value.type === 'JSXExpressionContainer' && node.value.expression.type === 'Literal') { + start = node.value.expression.range[0] + 1; + end = node.value.expression.range[1] - 1; + } + + const fixerFn = (fixer, invalidClassnames) => { + // Get the full class name string + const sourceCode = context.getSourceCode(); + const text = sourceCode.getText().slice(start, end); + + let updatedText = text; + + // Replace all invalid class names + invalidClassnames.forEach(item => { + const classNameRegex = new RegExp(`\\b${item.original}\\b`, 'g'); + updatedText = updatedText.replace(classNameRegex, item.suggestion); + }); + + return fixer.replaceTextRange([start, end], updatedText); + }; + + const { classNames } = astUtil.extractClassnamesFromValue(originalClassNamesValue); + parseForInvalidArbitraryValueSyntax(classNames, node, fixerFn); + } + } + }; + + const callExpressionVisitor = function (node) { + const calleeStr = astUtil.calleeToString(node.callee); + if (callees.findIndex((name) => calleeStr === name) === -1) { + return; + } + + node.arguments.forEach((arg) => { + if (arg.type === 'Literal' && typeof arg.value === 'string') { + const { classNames } = astUtil.extractClassnamesFromValue(arg.value); + + const fixerFn = (fixer, invalidClassnames) => { + const sourceCode = context.getSourceCode(); + const text = sourceCode.getText(arg); + const innerText = text.slice(1, -1); // Remove quotes + + let updatedText = innerText; + + // Replace all invalid class names + invalidClassnames.forEach(item => { + const classNameRegex = new RegExp(`\\b${item.original}\\b`, 'g'); + updatedText = updatedText.replace(classNameRegex, item.suggestion); + }); + + // Use the same type of quote + const quote = text[0]; + return fixer.replaceText(arg, `${quote}${updatedText}${quote}`); + }; + + parseForInvalidArbitraryValueSyntax(classNames, arg, fixerFn); + } else { + astUtil.parseNodeRecursive(node, arg, (classNames, nestedNode) => { + parseForInvalidArbitraryValueSyntax(classNames, nestedNode, null); + }, false, false, ignoredKeys); + } + }); + }; + + const scriptVisitor = { + JSXAttribute: attributeVisitor, + TextAttribute: attributeVisitor, + CallExpression: callExpressionVisitor, + TaggedTemplateExpression: function (node) { + if (!tags.includes(node.tag.name ?? node.tag.object?.name ?? node.tag.callee?.name)) { + return; + } + astUtil.parseNodeRecursive(node, node.quasi, (classNames, nestedNode) => { + parseForInvalidArbitraryValueSyntax(classNames, nestedNode, null); + }, false, false, ignoredKeys); + }, + }; + + // With the vue-eslint-parser + if (parserUtil.defineTemplateBodyVisitor) { + return parserUtil.defineTemplateBodyVisitor( + context, + { + VAttribute: function (node) { + if (!astUtil.isValidVueAttribute(node, classRegex)) { + return; + } + + if (astUtil.isVLiteralValue(node)) { + const { classNames } = astUtil.extractClassnamesFromValue(node.value.value); + parseForInvalidArbitraryValueSyntax(classNames, node, null); + } else if (astUtil.isArrayExpression(node)) { + node.value.expression.elements.forEach((element) => { + if (element.type === 'Literal' && typeof element.value === 'string') { + const { classNames } = astUtil.extractClassnamesFromValue(element.value); + parseForInvalidArbitraryValueSyntax(classNames, element, null); + } + }); + } else if (astUtil.isObjectExpression(node)) { + node.value.expression.properties.forEach((property) => { + if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + const { classNames } = astUtil.extractClassnamesFromValue(property.key.value); + parseForInvalidArbitraryValueSyntax(classNames, property.key, null); + } + }); + } + }, + VElement: function (node) { + // Look for + // the "class" attribute is attached to the VElement + // (no dynamic value) + if (node.startTag.attributes) { + node.startTag.attributes.forEach((attr) => { + if (attr.key && attr.key.name === 'class' && attr.value) { + const { classNames } = astUtil.extractClassnamesFromValue(attr.value.value); + parseForInvalidArbitraryValueSyntax(classNames, attr, null); + } + }); + } + }, + }, + scriptVisitor + ); + } + + return scriptVisitor; + }, +}; \ No newline at end of file diff --git a/tests/lib/rules/enforces-arbitrary-value-syntax.js b/tests/lib/rules/enforces-arbitrary-value-syntax.js new file mode 100644 index 00000000..27a4011d --- /dev/null +++ b/tests/lib/rules/enforces-arbitrary-value-syntax.js @@ -0,0 +1,186 @@ +/** + * @fileoverview Enforces correct arbitrary value syntax (using square brackets) + * @author Sweet + */ +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +var rule = require("../../../lib/rules/enforces-arbitrary-value-syntax"); +var RuleTester = require("eslint").RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +var parserOptions = { + ecmaVersion: 2019, + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, +}; + +var defaultOptions = [ + { + config: { + theme: {}, + plugins: [], + separator: ':', + }, + }, +]; + +var invalidClassnames = [ + 'p-10px', + 'mx-15px', + 'leading-20px', + 'text-60px', + 'w-800px', + 'h-100vh', + 'gap-5rem', + 'm-1em', + 'rotate-10deg', + 'delay-500ms', + 'h-0.5px', + 'w-3.7rem', + 'mt-20rpx', + 'pt-1.25vmin', + 'width-5fr', +]; + +var invalidClassnamesFixed = [ + 'p-[10px]', + 'mx-[15px]', + 'leading-[20px]', + 'text-[60px]', + 'w-[800px]', + 'h-[100vh]', + 'gap-[5rem]', + 'm-[1em]', + 'rotate-[10deg]', + 'delay-[500ms]', + 'h-[0.5px]', + 'w-[3.7rem]', + 'mt-[20rpx]', + 'pt-[1.25vmin]', + 'width-[5fr]', +]; + +var validClassnames = [ + 'p-[10px]', + 'mx-[15px]', + 'leading-[20px]', + 'text-[60px]', + 'w-[800px]', + 'p-2', + 'mx-4', + 'text-lg', + 'rounded-md', + 'bg-blue-500', + 'w-1/2', + 'w-full', + 'w-100%', + 'scale-100', + 'opacity-50', +]; + +var ruleTester = new RuleTester({ parserOptions }); +ruleTester.run("enforces-arbitrary-value-syntax", rule, { + valid: [ + { + code: `
`, + options: defaultOptions, + }, + { + code: `
`, + options: defaultOptions, + }, + { + code: `classnames('${validClassnames.join("', '")}')`, + options: defaultOptions, + }, + { + code: `clsx('${validClassnames.join("', '")}')`, + options: defaultOptions, + }, + { + code: `
`, + options: defaultOptions, + }, + ], + + invalid: [ + { + code: `
`, + output: `
`, + options: defaultOptions, + errors: invalidClassnames.map(className => ({ + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: className, + suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)], + }, + })), + }, + { + code: `
`, + output: `
`, + options: defaultOptions, + errors: invalidClassnames.map(className => ({ + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: className, + suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)], + }, + })), + }, + { + code: `classnames('${invalidClassnames.join("', '")}')`, + output: `classnames('${invalidClassnamesFixed.join("', '")}')`, + options: defaultOptions, + errors: invalidClassnames.map(className => ({ + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: className, + suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)], + }, + })), + }, + { + code: `clsx('${invalidClassnames.join("', '")}')`, + output: `clsx('${invalidClassnamesFixed.join("', '")}')`, + options: defaultOptions, + errors: invalidClassnames.map(className => ({ + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: className, + suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)], + }, + })), + }, + { + code: `
`, + output: `
`, + options: defaultOptions, + errors: [ + { + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: invalidClassnames[0], + suggestion: invalidClassnamesFixed[0], + }, + }, + { + messageId: 'invalidArbitraryValueSyntax', + data: { + classname: invalidClassnames[1], + suggestion: invalidClassnamesFixed[1], + }, + }, + ], + }, + ], +}); \ No newline at end of file