Skip to content

Commit 2741be6

Browse files
committed
feat(no-unlocalized-strings): support cn() in styling variable assignments
- const indicatorClassName = cn("...", {...}) now works - Properly rejects nested fn() calls and nested objects inside styling constants - Tracks CallExpression and ObjectExpression depth to avoid false positives
1 parent 72ac61d commit 2741be6

File tree

2 files changed

+66
-22
lines changed

2 files changed

+66
-22
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ ruleTester.run("no-unlocalized-strings", noUnlocalizedStrings, {
179179
},
180180
{ code: 'const statusColors = { active: "#00ff00", inactive: "#cccccc" }', filename: "test.tsx" },
181181
{ code: 'const iconSizes = { sm: "w-4 h-4", lg: "w-8 h-8" }', filename: "test.tsx" },
182+
// Styling variable with cn() function call
183+
{
184+
code: `const indicatorClassName = cn(
185+
"shrink-0 rounded-[2px] border-border",
186+
{ "h-2.5 w-2.5": indicator === "dot", "w-1": indicator === "line" }
187+
)`,
188+
filename: "test.tsx"
189+
},
190+
{ code: 'const buttonClassName = cn("px-4 py-2", condition && "bg-blue-500")', filename: "test.tsx" },
182191
// Nested objects should NOT be ignored (only direct property values)
183192
// These are in the invalid section below
184193

src/rules/no-unlocalized-strings.ts

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -656,37 +656,72 @@ function isIgnoredProperty(node: TSESTree.Node, ignoreProperties: string[]): boo
656656
}
657657

658658
/**
659-
* Checks if a string is a direct property value in an object assigned to a styling constant.
659+
* Checks if a string is inside a variable assignment to a styling constant/variable.
660660
*
661-
* Only matches the exact structure:
661+
* Matches structures like:
662662
* const STATUS_COLORS = { active: "bg-green-100..." }
663+
* const colorClasses = { primary: "text-blue-500" }
664+
* const indicatorClassName = cn("shrink-0", { "w-1": condition })
663665
*
664-
* Does NOT match strings inside functions, IIFEs, or nested structures:
665-
* const STATUS_COLORS = { active: (() => "value")() } // ❌ not matched
666-
* const STATUS_COLORS = { active: fn("value") } // ❌ not matched
666+
* Does NOT match nested function calls or nested objects:
667+
* const STATUS_COLORS = { active: fn("Hello") } // fn() is nested in property
668+
* const STATUS_COLORS = { active: { x: "Hello" } } // nested object
667669
*/
668670
function isInsideStylingConstant(node: TSESTree.Node): boolean {
669-
// Must be: Literal → Property (as value) → ObjectExpression → VariableDeclarator
670-
const property = node.parent
671-
if (property?.type !== AST_NODE_TYPES.Property || property.value !== node) {
672-
return false
673-
}
671+
let current: TSESTree.Node | undefined = node.parent ?? undefined
672+
let lastCallExpression: TSESTree.Node | undefined = undefined
673+
let objectDepth = 0
674674

675-
const objectExpr = property.parent
676-
if (objectExpr.type !== AST_NODE_TYPES.ObjectExpression) {
677-
return false
678-
}
675+
while (current !== undefined) {
676+
// Track the most recent CallExpression we've passed through
677+
if (current.type === AST_NODE_TYPES.CallExpression) {
678+
lastCallExpression = current
679+
}
679680

680-
const declarator = objectExpr.parent
681-
if (
682-
declarator.type !== AST_NODE_TYPES.VariableDeclarator ||
683-
declarator.id.type !== AST_NODE_TYPES.Identifier ||
684-
declarator.init !== objectExpr
685-
) {
686-
return false
681+
// Track object nesting depth
682+
if (current.type === AST_NODE_TYPES.ObjectExpression) {
683+
objectDepth++
684+
}
685+
686+
// Found a variable declarator - check if it has a styling name
687+
if (
688+
current.type === AST_NODE_TYPES.VariableDeclarator &&
689+
current.id.type === AST_NODE_TYPES.Identifier &&
690+
isStylingConstant(current.id.name)
691+
) {
692+
// Check if the init is what we expect
693+
const init = current.init
694+
695+
// Case 1: Direct object - const x = { key: "value" }
696+
if (init?.type === AST_NODE_TYPES.ObjectExpression) {
697+
// Only allow if:
698+
// - We didn't pass through a CallExpression (fn() inside property)
699+
// - We didn't pass through nested objects (depth must be 1)
700+
return lastCallExpression === undefined && objectDepth === 1
701+
}
702+
703+
// Case 2: Direct function call - const x = cn("value", {...})
704+
if (init?.type === AST_NODE_TYPES.CallExpression) {
705+
// Only allow if the CallExpression we passed through IS the init
706+
return lastCallExpression === init
707+
}
708+
709+
return false
710+
}
711+
712+
// Stop at function boundaries (don't cross into function bodies)
713+
if (
714+
current.type === AST_NODE_TYPES.FunctionDeclaration ||
715+
current.type === AST_NODE_TYPES.FunctionExpression ||
716+
current.type === AST_NODE_TYPES.ArrowFunctionExpression
717+
) {
718+
return false
719+
}
720+
721+
current = current.parent ?? undefined
687722
}
688723

689-
return isStylingConstant(declarator.id.name)
724+
return false
690725
}
691726

692727
// ============================================================================

0 commit comments

Comments
 (0)