Skip to content
Merged
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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
- 🔧 **Auto-fix Support**: Automatically fixes non-canonical classes using ESLint's auto-fix feature
- 🎯 **Tailwind CSS v4 Integration**: Uses Tailwind CSS v4's official `canonicalizeCandidates` API
- 📝 **Multiple Format Support**: Works with string literals, template literals, ternaries, `&&`/`||`, arrays, and object keys
- 🛠️ **Utility Function Support**: Detects and fixes classes in utility functions like `cn()`, `clsx()`, `classNames()`, `twMerge()`, and `cva()`
- 🛠️ **Utility Function Support**: Detects and fixes classes in utility functions like `cn()`, `clsx()`, `classNames()`, `twMerge()`, `cva()`, and `tv()`, and in `` tw`…` `` tagged templates
- ⚡ **Zero Config**: Minimal configuration required to get started

## ⚖️ Comparison
Expand All @@ -44,7 +44,7 @@ Two larger plugins also ship a canonical-classes rule built on the same Tailwind
| Rule time, 400 JSX files, 12k class strings, first run | 1.2 s | 1.2 s | 9.8 s |
| Same, next runs (on-disk cache) | 0.1 s | 1.2 s | 9.8 s |
| Ternaries, template literals with `${}`, `clsx` object keys, Vue `:class` object/array bindings | Checked | Checked | Checked |
| `cva` variants, `tv()`, `` tw`…` `` tagged templates | Not checked | Checked | Checked |
| `cva` variants, `tv()`, `` tw`…` `` tagged templates | Checked | Checked | Checked |

Measured on 2026-10-20 with ESLint 9.39, Tailwind CSS 4.3.3, `eslint-plugin-tailwindcss` 4.4.0, and `eslint-plugin-better-tailwindcss` 4.10.0. Rule time is total lint time minus a run with the rule off, median of five runs.

Expand Down Expand Up @@ -134,7 +134,7 @@ export default [
{
cssPath: './app/styles/globals.css', // Required
rootFontSize: 16, // Optional, default: 16
calleeFunctions: ['cn', 'clsx'], // Optional, default: ['cn', 'clsx', 'classNames', 'twMerge', 'cva']
calleeFunctions: ['cn', 'clsx'], // Optional, default: ['cn', 'clsx', 'classNames', 'twMerge', 'cva', 'tv', 'tw']
},
],
},
Expand Down Expand Up @@ -272,8 +272,8 @@ rootFontSize: 14 // If your root font size is 14px
### `calleeFunctions` (optional)

- **Type**: `string[]`
- **Default**: `['cn', 'clsx', 'classNames', 'twMerge', 'cva']`
- **Description**: Array of utility function names to check for Tailwind classes. The plugin will detect and canonicalize classes passed as string arguments to these functions.
- **Default**: `['cn', 'clsx', 'classNames', 'twMerge', 'cva', 'tv', 'tw']`
- **Description**: Names of functions whose arguments hold Tailwind classes. A name also matches tagged templates, such as `` tw`…` `` and `` tw.div`…` ``. For `cva` and `tv`, classes are read from the config values (`base`, `slots`, variant options, and `class`/`className` in compound entries); `defaultVariants` is skipped.

**Example**:
```javascript
Expand Down Expand Up @@ -320,6 +320,9 @@ The plugin supports various class name formats:
```tsx
<div className={cn("p-4px", "m-2rem")}>Content</div>
<div className={clsx("w-[16px]", condition && "hidden")}>Content</div>
const button = cva("w-[16px]", { variants: { size: { sm: "h-[32px]" } } });
const card = tv({ base: "p-4px", slots: { title: "m-2rem" } });
const Title = tw.h1`text-[16px]`;
// Variables and other dynamic values are skipped
```

Expand Down Expand Up @@ -397,7 +400,9 @@ Set `TAILWIND_CANONICAL_CLASSES_CACHE_DIR` to store the cache somewhere else, fo
- **Tailwind CSS v4 required**: Requires Tailwind CSS v4 (not compatible with v3)
- **CSS file accessibility**: CSS file must be accessible from the ESLint process
- **Template literals**: A class touching a `${}` expression (for example `bg-${color}`) is skipped
- **Dynamic values**: Variables, computed object keys, and object values are skipped; `cva` variant objects and `tv()` configs are not checked
- **Dynamic values**: Variables, computed object keys, and `clsx` object values are skipped
- **`cva`/`tv` class objects**: A `clsx`-style object written directly in a `cva` or `tv` config (for example `base: { 'w-4': active }`) is read as config, so its class keys are skipped; wrap it in `clsx()` to have it checked
- **Tagged templates**: Only `` tw`…` `` and `` tw.<tag>`…` `` match; deeper chains such as `` tw.colors.div`…` `` are skipped
- **Framework parsers**: `flat/svelte` and `flat/vue` require `svelte-eslint-parser` and `vue-eslint-parser` respectively

## 🐛 Troubleshooting
Expand Down
140 changes: 122 additions & 18 deletions src/rules/class-sources/class-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,36 @@ import type * as ESTree from 'estree';
import { getQuoteChar, joinClasses, splitClasses } from './estree.js';
import type { CanonicalizationContext, ClassSource } from './types.js';

export function isClassCall(
/**
* Returns the name an identifier or member expression ends with: `cn` for
* both `cn` and `utils.cn`. Used for callees and for object property keys.
*/
function getNameOf(node: ESTree.Node): string | null {
if (node.type === 'Identifier') {
return node.name;
}
return node.type === 'MemberExpression' && node.property.type === 'Identifier'
? node.property.name
: null;
}

/**
* Returns the function name when `node` calls a configured class function,
* either as `cn(...)` or as a tagged template such as `` tw`...` `` or `` tw.div`...` ``.
*/
export function getClassFunctionName(
node: ESTree.Node,
calleeFunctions: string[],
): node is ESTree.CallExpression {
if (node.type !== 'CallExpression') {
return false;
): string | null {
let name: string | null = null;
if (node.type === 'CallExpression') {
name = getNameOf(node.callee);
} else if (node.type === 'TaggedTemplateExpression') {
// Only `tw` and `tw.<tag>`: in `theme.tw` or `tw.colors.div`, `tw` is not the tag function.
const target = node.tag.type === 'MemberExpression' ? node.tag.object : node.tag;
name = target.type === 'Identifier' ? target.name : null;
}
const { callee } = node;
const name =
callee.type === 'Identifier'
? callee.name
: callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
? callee.property.name
: null;
return name !== null && calleeFunctions.includes(name);
return name !== null && calleeFunctions.includes(name) ? name : null;
}

/**
Expand Down Expand Up @@ -106,19 +121,108 @@ function walk(
}
return;
case 'CallExpression':
if (isClassCall(node, ctx.calleeFunctions)) {
// The CallExpression visitor skips calls collected here, so each is reported once.
ctx.walkedClassCalls.add(node);
for (const arg of node.arguments) {
walk(arg, ctx, sources);
}
case 'TaggedTemplateExpression': {
const name = getClassFunctionName(node, ctx.calleeFunctions);
if (name === null) {
return;
}
// The script visitor skips class calls collected here, so each is reported once.
ctx.walkedClassCalls.add(node);
if (node.type === 'TaggedTemplateExpression') {
walk(node.quasi, ctx, sources);
} else {
walkClassFunctionArguments(node, name, ctx, sources);
}
return;
}
default:
return;
}
}

function walkClassFunctionArguments(
node: ESTree.CallExpression,
name: string,
ctx: CanonicalizationContext,
sources: ClassSource[],
): void {
if (name === 'cva') {
// cva('base', { variants }) and cva({ base, variants })
node.arguments.forEach((arg) => walkVariantConfig(arg, ctx, sources));
} else if (name === 'tv') {
// The second tv() argument configures tailwind-merge, not classes.
walkVariantConfig(node.arguments[0], ctx, sources);
} else {
node.arguments.forEach((arg) => walk(arg, ctx, sources));
}
}

function getKeyName(property: ESTree.Property): string | null {
if (property.computed) {
return null;
}
return property.key.type === 'Literal' ? String(property.key.value) : getNameOf(property.key);
}

/**
* Walks a cva() or tv() config, where classes sit in property values:
* `base`, `slots`, and every variant option. `defaultVariants` holds variant
* names, and compound entries hold their classes only in `class` or `className`.
*/
function walkVariantConfig(
node: ESTree.Node | null | undefined,
ctx: CanonicalizationContext,
sources: ClassSource[],
): void {
if (node?.type === 'ArrayExpression') {
node.elements.forEach((element) => walkVariantConfig(element, ctx, sources));
return;
}
if (node?.type !== 'ObjectExpression') {
walk(node, ctx, sources);
return;
}
for (const property of node.properties) {
if (property.type !== 'Property') {
continue;
}
const key = getKeyName(property);
if (key === null || key === 'defaultVariants') {
continue;
}
if (key === 'compoundVariants' || key === 'compoundSlots') {
walkCompoundEntries(property.value, ctx, sources);
} else {
walkVariantConfig(property.value, ctx, sources);
}
}
}

/** Walks `compoundVariants` or `compoundSlots`, whose entries mix conditions with `class`/`className`. */
function walkCompoundEntries(
node: ESTree.Node,
ctx: CanonicalizationContext,
sources: ClassSource[],
): void {
if (node.type !== 'ArrayExpression') {
return;
}
for (const entry of node.elements) {
if (entry?.type !== 'ObjectExpression') {
continue;
}
for (const field of entry.properties) {
if (field.type !== 'Property') {
continue;
}
const key = getKeyName(field);
if (key === 'class' || key === 'className') {
walkVariantConfig(field.value, ctx, sources);
}
}
}
}

/**
* Checks the static text of one template literal chunk. A class glued to a
* `${}` expression, like `bg-${color}`, is only part of a class and is skipped.
Expand Down
2 changes: 2 additions & 0 deletions src/rules/class-sources/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export function setupRuleContext(
'classNames',
'twMerge',
'cva',
'tv',
'tw',
];

return {
Expand Down
4 changes: 2 additions & 2 deletions src/rules/class-sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ export interface CanonicalizationContext {
calleeFunctions: string[];
canonicalizeClasses: (candidates: string[]) => string[];
sourceText: string;
/** Class calls already collected while walking an enclosing class expression. */
walkedClassCalls: WeakSet<ESTree.CallExpression>;
/** Class calls and tagged templates already collected while walking an enclosing class expression. */
walkedClassCalls: WeakSet<ESTree.CallExpression | ESTree.TaggedTemplateExpression>;
}
26 changes: 12 additions & 14 deletions src/rules/tailwind-canonical-classes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { Rule } from 'eslint';
import {
collectClassExpressionSources,
isClassCall,
} from './class-sources/class-expression.js';
import type * as ESTree from 'estree';
import { collectClassExpressionSources } from './class-sources/class-expression.js';
import { getSourceCode, setupRuleContext } from './class-sources/context.js';
import { collectJsxClassNameSources } from './class-sources/jsx.js';
import { createClassSourceReporter } from './class-sources/pipeline.js';
Expand Down Expand Up @@ -94,17 +92,17 @@ const rule: Rule.RuleModule = {
},
};

const scriptVisitor: Rule.RuleListener = {
CallExpression(node) {
if (
ctx.walkedClassCalls.has(node) ||
!isClassCall(node, ctx.calleeFunctions)
) {
return;
}

const reportClassFunction = (
node: ESTree.CallExpression | ESTree.TaggedTemplateExpression,
) => {
if (!ctx.walkedClassCalls.has(node)) {
reportClassSources(collectClassExpressionSources(node, ctx));
},
}
};

const scriptVisitor: Rule.RuleListener = {
CallExpression: reportClassFunction,
TaggedTemplateExpression: reportClassFunction,
};

return wireRuleVisitors(
Expand Down
63 changes: 63 additions & 0 deletions tests/tailwind-canonical-classes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,72 @@ describe('tailwind-canonical-classes', () => {
code: '<div className={`w-[16px]-${size} ${a}w-[16px]`}>Content</div>',
options: [{ cssPath }],
},
{
code: 'const a = css`w-[16px]`; const b = styled.div`h-[32px]`; const c = tw.colors.div`p-[4px]`; const d = theme.tw`m-[8px]`;',
options: [{ cssPath }],
},
],

invalid: [
{
code: 'const badge = cva({ base: "w-[16px]", variants: { tone: { muted: "h-[32px]" } } });',
output: 'const badge = cva({ base: "w-4", variants: { tone: { muted: "h-8" } } });',
options: [{ cssPath }],
errors: [
{ messageId: 'nonCanonical', data: { original: 'w-[16px]', canonical: 'w-4' } },
{ messageId: 'nonCanonical', data: { original: 'h-[32px]', canonical: 'h-8' } },
],
},
{
code: `const button = cva("w-[16px]", {
variants: { size: { sm: "h-[32px]", lg: ["p-[4px]"] } },
compoundVariants: [{ size: "m-[8px]", class: "bg-[#fff]" }],
defaultVariants: { size: "w-[20px]" },
});`,
output: `const button = cva("w-4", {
variants: { size: { sm: "h-8", lg: ["p-1"] } },
compoundVariants: [{ size: "m-[8px]", class: "bg-white" }],
defaultVariants: { size: "w-[20px]" },
});`,
options: [{ cssPath }],
errors: [
{ messageId: 'nonCanonical', data: { original: 'w-[16px]', canonical: 'w-4' } },
{ messageId: 'nonCanonical', data: { original: 'h-[32px]', canonical: 'h-8' } },
{ messageId: 'nonCanonical', data: { original: 'p-[4px]', canonical: 'p-1' } },
{ messageId: 'nonCanonical', data: { original: 'bg-[#fff]', canonical: 'bg-white' } },
],
},
{
code: `const card = tv({
base: "w-[16px]",
slots: { title: "h-[32px]" },
variants: { tone: { dark: { title: "bg-[#000]" } } },
compoundSlots: [{ slots: ["title"], tone: "m-[8px]", className: "p-[4px]" }],
}, { twMergeConfig: { classGroups: { x: ["w-[20px]"] } } });`,
output: `const card = tv({
base: "w-4",
slots: { title: "h-8" },
variants: { tone: { dark: { title: "bg-black" } } },
compoundSlots: [{ slots: ["title"], tone: "m-[8px]", className: "p-1" }],
}, { twMergeConfig: { classGroups: { x: ["w-[20px]"] } } });`,
options: [{ cssPath }],
errors: [
{ messageId: 'nonCanonical', data: { original: 'w-[16px]', canonical: 'w-4' } },
{ messageId: 'nonCanonical', data: { original: 'h-[32px]', canonical: 'h-8' } },
{ messageId: 'nonCanonical', data: { original: 'bg-[#000]', canonical: 'bg-black' } },
{ messageId: 'nonCanonical', data: { original: 'p-[4px]', canonical: 'p-1' } },
],
},
{
code: 'const a = tw`w-[16px] ${x}`; const B = tw.div`h-[32px]`; const c = <div className={tw`p-[4px]`} />;',
output: 'const a = tw`w-4 ${x}`; const B = tw.div`h-8`; const c = <div className={tw`p-1`} />;',
options: [{ cssPath }],
errors: [
{ messageId: 'nonCanonical', data: { original: 'w-[16px]', canonical: 'w-4' } },
{ messageId: 'nonCanonical', data: { original: 'h-[32px]', canonical: 'h-8' } },
{ messageId: 'nonCanonical', data: { original: 'p-[4px]', canonical: 'p-1' } },
],
},
{
code: '<div className={active ? "w-[16px]" : "h-8"}>Content</div>',
output: '<div className={active ? "w-4" : "h-8"}>Content</div>',
Expand Down
Loading