Skip to content

Commit eda6256

Browse files
committed
feat(no-unlocalized-strings): support className utility functions
- Strings inside cn(), clsx(), classnames(), twMerge() etc. are now ignored - Walks up the tree to find parent styling property (className, *Color, etc.) - Stops at function boundaries to avoid ignoring onClick callbacks
1 parent 32cfd3b commit eda6256

File tree

4 files changed

+118
-5
lines changed

4 files changed

+118
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const status: Status = "loading" // String literal union
2424

2525
// ✅ Automatically ignored - styling props, constants, and numeric strings
2626
<Box containerClassName="flex items-center" /> // *ClassName, *Class, *Color, etc.
27+
<div className={clsx("px-4", "py-2")} /> // className utilities (cx, clsx, etc.)
2728
const STATUS_COLORS = { active: "bg-green-100" } // *_COLORS, *_CLASSES, etc.
2829
const price = "1,00€" // No letters = technical
2930

docs/rules/no-unlocalized-strings.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,17 @@ In addition to the explicit list, the rule automatically ignores camelCase prope
175175
<Box backgroundColor="#ff0000" />
176176
<Card backgroundImage="url(/hero.jpg)" />
177177
<Avatar iconSize="24" />
178+
179+
// Also works with className utility functions (cn, clsx, classnames, etc.)
180+
<div className={cn("px-4 py-2", "text-white")} />
181+
<div className={clsx("base", condition && "extra")} />
182+
<div className={condition ? "class-a" : "class-b"} />
178183
```
179184

180185
This covers common patterns in component libraries like Chakra UI, Material UI, and custom component props.
181186

187+
**Note**: Strings inside callback functions (like `onClick`) are NOT ignored, even when `className` is present on the same element.
188+
182189
#### Auto-Detected Styling Constants
183190

184191
UPPER_CASE constant names with styling-related suffixes are also automatically ignored:

src/rules/no-unlocalized-strings.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
9595
// Complex camelCase class name properties
9696
{ code: '<Select inputElementClassName="text-sm placeholder-gray-400" />', filename: "test.tsx" },
9797
{ code: '<DatePicker calendarPopoverClassName="shadow-lg rounded-xl" />', filename: "test.tsx" },
98+
99+
// Strings inside className utility functions (cn, clsx, classnames, etc.)
100+
{ code: '<div className={cn("px-4 py-2", "text-white")} />', filename: "test.tsx" },
101+
{ code: '<div className={clsx("base-class", condition && "conditional-class")} />', filename: "test.tsx" },
102+
{ code: '<div className={classnames("a", "b", "c")} />', filename: "test.tsx" },
103+
{ code: '<div className={twMerge("px-4", "px-8")} />', filename: "test.tsx" },
104+
{ code: '<div className={condition ? "class-a" : "class-b"} />', filename: "test.tsx" },
105+
{
106+
code: '<div className={cn("base", variant === "primary" ? "bg-blue-500" : "bg-gray-500")} />',
107+
filename: "test.tsx"
108+
},
109+
// Nested function calls
110+
{ code: '<div className={cn(baseStyles, getVariantClass("primary"))} />', filename: "test.tsx" },
98111
// Color properties
99112
{ code: '<Box backgroundColor="#ff0000" />', filename: "test.tsx" },
100113
{ code: '<Text textColor="red-500" />', filename: "test.tsx" },
@@ -455,6 +468,18 @@ ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
455468
code: 'const STATUS_COLORS = { active: { nested: "Hello World" } }',
456469
filename: "test.tsx",
457470
errors: [{ messageId: "unlocalizedString" }]
471+
},
472+
473+
// Strings inside callbacks should NOT be ignored (even when className is present)
474+
{
475+
code: '<button className="px-4" onClick={() => alert("Hello World")}>X</button>',
476+
filename: "test.tsx",
477+
errors: [{ messageId: "unlocalizedString" }]
478+
},
479+
{
480+
code: '<button className={cn("px-4")} onSubmit={() => showMessage("Form submitted!")}>X</button>',
481+
filename: "test.tsx",
482+
errors: [{ messageId: "unlocalizedString" }]
458483
}
459484
]
460485
})

src/rules/no-unlocalized-strings.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,82 @@ function isStylingConstant(variableName: string): boolean {
533533
}
534534

535535
/**
536-
* Checks if a string is a value for an ignored property/attribute.
536+
* Gets the property name if this node is (directly) a property value.
537+
*/
538+
function getPropertyName(node: TSESTree.Node): string | null {
539+
const parent = node.parent
540+
541+
// JSX attribute: <div className="..." /> or <div className={...} />
542+
if (parent?.type === AST_NODE_TYPES.JSXAttribute && parent.name.type === AST_NODE_TYPES.JSXIdentifier) {
543+
return parent.name.name
544+
}
545+
546+
// JSX expression container unwrap: className={...}
547+
if (parent?.type === AST_NODE_TYPES.JSXExpressionContainer) {
548+
const attr = parent.parent
549+
if (attr.type === AST_NODE_TYPES.JSXAttribute && attr.name.type === AST_NODE_TYPES.JSXIdentifier) {
550+
return attr.name.name
551+
}
552+
}
553+
554+
// Object property: { className: "..." }
555+
if (parent?.type === AST_NODE_TYPES.Property && parent.value === node) {
556+
if (parent.key.type === AST_NODE_TYPES.Identifier) {
557+
return parent.key.name
558+
}
559+
if (parent.key.type === AST_NODE_TYPES.Literal && typeof parent.key.value === "string") {
560+
return parent.key.value
561+
}
562+
}
563+
564+
return null
565+
}
566+
567+
/**
568+
* Checks if a property name should be ignored (styling/technical property).
569+
*/
570+
function isTechnicalPropertyName(name: string, ignoreProperties: string[]): boolean {
571+
return ignoreProperties.includes(name) || isStylingProperty(name)
572+
}
573+
574+
/**
575+
* Checks if a string is anywhere inside the value of a styling property.
576+
*
577+
* This handles complex patterns like:
578+
* className={cn("class1", "class2")}
579+
* className={cn("base", condition && "extra")}
580+
* className={condition ? "a" : "b"}
581+
*
582+
* Walks up the tree looking for a JSXAttribute or Property with a styling name.
583+
*/
584+
function isInsideStylingPropertyValue(node: TSESTree.Node, ignoreProperties: string[]): boolean {
585+
let current: TSESTree.Node | undefined = node
586+
587+
while (current !== undefined) {
588+
// Check if current node is directly the value of a styling property
589+
const propName = getPropertyName(current)
590+
if (propName !== null) {
591+
return isTechnicalPropertyName(propName, ignoreProperties)
592+
}
593+
594+
// Stop at function declarations/expressions (don't cross function boundaries)
595+
// This prevents: onClick={() => { return "Hello" }} from being ignored
596+
if (
597+
current.type === AST_NODE_TYPES.FunctionDeclaration ||
598+
current.type === AST_NODE_TYPES.FunctionExpression ||
599+
current.type === AST_NODE_TYPES.ArrowFunctionExpression
600+
) {
601+
return false
602+
}
603+
604+
current = current.parent ?? undefined
605+
}
606+
607+
return false
608+
}
609+
610+
/**
611+
* Checks if a string is a value for an ignored property/attribute (direct value only).
537612
*/
538613
function isIgnoredProperty(node: TSESTree.Node, ignoreProperties: string[]): boolean {
539614
const parent = node.parent
@@ -542,7 +617,7 @@ function isIgnoredProperty(node: TSESTree.Node, ignoreProperties: string[]): boo
542617
if (parent?.type === AST_NODE_TYPES.JSXAttribute) {
543618
if (parent.name.type === AST_NODE_TYPES.JSXIdentifier) {
544619
const name = parent.name.name
545-
if (ignoreProperties.includes(name) || isStylingProperty(name)) {
620+
if (isTechnicalPropertyName(name, ignoreProperties)) {
546621
return true
547622
}
548623
}
@@ -552,13 +627,13 @@ function isIgnoredProperty(node: TSESTree.Node, ignoreProperties: string[]): boo
552627
if (parent?.type === AST_NODE_TYPES.Property) {
553628
if (parent.key.type === AST_NODE_TYPES.Identifier) {
554629
const name = parent.key.name
555-
if (ignoreProperties.includes(name) || isStylingProperty(name)) {
630+
if (isTechnicalPropertyName(name, ignoreProperties)) {
556631
return true
557632
}
558633
}
559634
if (parent.key.type === AST_NODE_TYPES.Literal && typeof parent.key.value === "string") {
560635
const name = parent.key.value
561-
if (ignoreProperties.includes(name) || isStylingProperty(name)) {
636+
if (isTechnicalPropertyName(name, ignoreProperties)) {
562637
return true
563638
}
564639
}
@@ -938,11 +1013,16 @@ export const noUnlocalizedStrings = createRule<[Options], MessageId>({
9381013
return
9391014
}
9401015

941-
// Value for ignored property
1016+
// Value for ignored property (direct)
9421017
if (isIgnoredProperty(node, options.ignoreProperties)) {
9431018
return
9441019
}
9451020

1021+
// Inside a styling property value (e.g., className={cn("class1", "class2")})
1022+
if (isInsideStylingPropertyValue(node, options.ignoreProperties)) {
1023+
return
1024+
}
1025+
9461026
// Inside a styling constant (e.g., STATUS_COLORS, BUTTON_CLASSES)
9471027
if (isInsideStylingConstant(node)) {
9481028
return

0 commit comments

Comments
 (0)