Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Learn more about each supported rules by reading their documentation:
- [`no-arbitrary-value`](docs/rules/no-arbitrary-value.md): forbid using arbitrary values in classnames (turned off by default)
- [`no-custom-classname`](docs/rules/no-custom-classname.md): only allow classnames from Tailwind CSS and the values from the `whitelist` option
- [`no-contradicting-classname`](docs/rules/no-contradicting-classname.md): e.g. avoid `p-2 p-3`, different Tailwind CSS classnames (`pt-2` & `pt-3`) but targeting the same property several times for the same variant.
- [`no-string-interpolation`](docs/rules/no-string-interpolation.md): forbid dynamic Tailwind class construction
- [`no-unnecessary-arbitrary-value`](docs/rules/no-unnecessary-arbitrary-value.md): e.g. replacing `m-[1.25rem]` by its configuration based classname `m-5`

Using ESLint extension for Visual Studio Code, you will get these messages
Expand Down Expand Up @@ -175,7 +176,6 @@ Our recommendations:
#### For `eslint.config.js`

- For `js[x]`, `ts[x]`:

- Install the parser: `npm i -D @eslint/js typescript-eslint`
- Assign it to your files in `eslint.config.js`:

Expand All @@ -196,7 +196,6 @@ Our recommendations:
```

- For `vue.js`:

- Install the parser: `npm i -D eslint-plugin-vue`
- Assign it to your files in `eslint.config.js`:

Expand Down Expand Up @@ -315,6 +314,5 @@ The plugin will look for each setting in this order and stops searching as soon
- `no-redundant-variant`: e.g. avoid `mx-5 sm:mx-5`, no need to redefine `mx` in `sm:` variant as it uses the same value (`5`)

- `only-valid-arbitrary-values`:

- e.g. avoid `top-[42]`, only `0` value can be unitless.
- e.g. avoid `text-[rgba(10%,20%,30,50%)]`, can't mix `%` and `0-255`.
32 changes: 32 additions & 0 deletions docs/rules/no-string-interpolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Disallow dynamic Tailwind class construction (no-string-interpolation)

Detect whether invalid string interpolation is used to generate Tailwind classnames.

## Rule Details

This rule reports use of string interpolation in class attributes when the interpolated value is part of the class name string (e.g. `bg-${color}`).
Tailwind CSS scans your source code for class names. If you use string interpolation to construct class names, Tailwind CSS will not find them and will not generate the corresponding CSS.

Examples of **incorrect** code for this rule:

```jsx
<div className={`bg-${color}`}></div>
<div className={`text-${size}`}></div>
```

Examples of **correct** code for this rule:

```jsx
// Use full class names
<div className={color === 'red' ? 'bg-red-500' : 'bg-blue-500'}></div>

// Or use a library like `classnames` or `clsx` (if configured)
<div className={classnames({ 'bg-red-500': isRed, 'bg-blue-500': !isRed })}></div>

// Interpolation is allowed if it's not part of a class name construction
<div className={`text-center ${color}`}></div>
```

## Further Reading

- [Tailwind CSS Documentation - Dynamic class names](https://tailwindcss.com/docs/content-configuration#dynamic-class-names)
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module.exports = {
'classnames-order': require(base + 'classnames-order'),
'enforces-negative-arbitrary-values': require(base + 'enforces-negative-arbitrary-values'),
'enforces-shorthand': require(base + 'enforces-shorthand'),
'no-string-interpolation': require(base + 'no-string-interpolation'),
'migration-from-tailwind-2': require(base + 'migration-from-tailwind-2'),
'no-arbitrary-value': require(base + 'no-arbitrary-value'),
'no-contradicting-classname': require(base + 'no-contradicting-classname'),
Expand Down
74 changes: 74 additions & 0 deletions lib/rules/no-string-interpolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @fileoverview Detect whether invalid string interpolation is used to generate Tailwind classnames.
* @author Angelos Athanasakos
*/
'use strict';


const docsUrl = require('../util/docsUrl');


//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

// Predefine message for use in context.report conditional.
// messageId will still be usable in tests.
const INVALID_STRING_INTERPOLATION_MESSAGE = 'Dynamic Tailwind class construction is not allowed. Use conditional classnames instead.';

module.exports = {
meta: {
type: 'problem',
category: 'Best Practices',
recommended: false,
url: docsUrl('no-string-interpolation'),
docs: {
description: 'Disallow dynamic Tailwind class construction inside class attributes',
},
schema: [],
messages: {
noInterpolation: INVALID_STRING_INTERPOLATION_MESSAGE,
},
},

create(context) {
function isClassAttribute(name) {
return name === 'class' || name === 'className';
}
function hasDynamicTailwindConstruction(templateLiteral) {
const { quasis, expressions } = templateLiteral;

if (!expressions.length) return false;

for (let i = 0; i < expressions.length; i++) {
const before = quasis[i].value.raw;
const after = quasis[i + 1].value.raw;

const beforeEndsWithSpace = /\s$/.test(before);
const afterStartsWithSpace = /^\s/.test(after);

if (!beforeEndsWithSpace && before !== '') return true;
if (!afterStartsWithSpace && after !== '') return true;
}

return false;
}

return {
JSXAttribute(node) {
const name = node.name?.name;
if (!isClassAttribute(name)) return;
if (!node.value) return;

if (node.value.type === 'JSXExpressionContainer' && node.value.expression.type === 'TemplateLiteral') {
if (hasDynamicTailwindConstruction(node.value.expression)) {
context.report({
node,
messageId: 'noInterpolation',
});
}
}
},
};
},
};
96 changes: 96 additions & 0 deletions tests/lib/rules/no-string-interpolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @fileoverview Disallow dynamic Tailwind class construction inside class attributes
* @author Angelos Athanasakos
*/
"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

var { RuleTester } = require("eslint");
var rule = require("../../../lib/rules/no-string-interpolation");

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

var RuleTester = require("eslint").RuleTester;

var parserOptions = {
ecmaVersion: 2019,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
};

var ruleTester = new RuleTester({ parserOptions });

ruleTester.run("no-string-interpolation", rule, {
valid: [
{
code: `<div className="bg-red-600 text-lg" />`,
},

{
code: `<div className={color} />`,
},

{
code: `<div className={\`\${color} text-lg\`} />`,
},

{
code: `<div className={\`text-lg \${color}\`} />`,
},

{
code: `<div className={\`\${color}\`} />`,
},

{
code: `
<div
className={classNames(containerClassName, 'w-22 text-center', {
inline: containerClassName.length === 0
})}
/>
`,
},

{
code: `
const Spinner = ({ circlesClassName = 'bg-primary', size }) => (
<>
<Bounce size={size} className={circlesClassName} />
<Bounce size={size} className={\`\${circlesClassName} animation-delay-16\`} />
<Bounce size={size} className={\`\${circlesClassName} animation-delay-32\`} />
</>
);
`,
},
],

invalid: [
{
code: `<div className={\`bg-\${color}-600\`} />`,
errors: [{ messageId: "noInterpolation" }],
},

{
code: `<div className={\`text-\${size}\`} />`,
errors: [{ messageId: "noInterpolation" }],
},

{
code: `<div className={\`p-\${spacing}-4\`} />`,
errors: [{ messageId: "noInterpolation" }],
},

{
code: `<div className={\`border-\${color}-\${shade}\`} />`,
errors: [{ messageId: "noInterpolation" }],
},
],
});