This commit is contained in:
shadcn
2026-05-10 20:36:15 +04:00
parent b8608d0976
commit 5be151c6d9
3 changed files with 230 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
handle cn-\* class transformation in non-className contexts

View File

@@ -1027,4 +1027,162 @@ function Menu({ className, ...props }: React.ComponentProps<"div">) {
"
`)
})
it("applies styles to cn-* classes in object properties (toastOptions pattern)", async () => {
const source = `import * as React from "react"
import { Toaster as Sonner } from "sonner"
const Toaster = ({ ...props }) => {
return (
<Sonner
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
`
const styleMap: StyleMap = {
"cn-toast": "rounded-2xl",
}
const result = await applyTransform(source, styleMap)
expect(result).toMatchInlineSnapshot(`
"import * as React from "react"
import { Toaster as Sonner } from "sonner"
const Toaster = ({ ...props }) => {
return (
<Sonner
toastOptions={{
classNames: {
toast: "rounded-2xl",
},
}}
{...props}
/>
)
}
"
`)
})
it("applies styles to cn-* classes in deeply nested object properties", async () => {
const source = `import * as React from "react"
const config = {
options: {
classNames: {
wrapper: "cn-wrapper existing-class",
inner: "cn-inner",
},
},
}
`
const styleMap: StyleMap = {
"cn-wrapper": "flex flex-col",
"cn-inner": "p-4 rounded-lg",
}
const result = await applyTransform(source, styleMap)
expect(result).toMatchInlineSnapshot(`
"import * as React from "react"
const config = {
options: {
classNames: {
wrapper: "flex flex-col existing-class",
inner: "p-4 rounded-lg",
},
},
}
"
`)
})
it("removes cn-* classes from object properties when not in styleMap", async () => {
const source = `import * as React from "react"
const config = {
classNames: {
item: "cn-unknown-class existing-class",
},
}
`
const styleMap: StyleMap = {}
const result = await applyTransform(source, styleMap)
expect(result).toMatchInlineSnapshot(`
"import * as React from "react"
const config = {
classNames: {
item: "existing-class",
},
}
"
`)
})
it("preserves allowlisted cn-* classes in object properties", async () => {
const source = `import * as React from "react"
const config = {
classNames: {
target: "cn-menu-target",
},
}
`
const styleMap: StyleMap = {
"cn-menu-target": "z-50 origin-top",
}
const result = await applyTransform(source, styleMap)
expect(result).toMatchInlineSnapshot(`
"import * as React from "react"
const config = {
classNames: {
target: "cn-menu-target",
},
}
"
`)
})
it("handles multiple cn-* classes in object property values", async () => {
const source = `import * as React from "react"
const options = {
toast: "cn-toast cn-toast-content extra-class",
}
`
const styleMap: StyleMap = {
"cn-toast": "rounded-lg",
"cn-toast-content": "p-4",
}
const result = await applyTransform(source, styleMap)
expect(result).toMatchInlineSnapshot(`
"import * as React from "react"
const options = {
toast: "rounded-lg p-4 extra-class",
}
"
`)
})
})

View File

@@ -41,6 +41,7 @@ export const transformStyleMap: TransformerStyle<SourceFile> = async ({
applyToCvaCalls(sourceFile, styleMap, matchedClasses)
applyToClassNameAttributes(sourceFile, styleMap, matchedClasses)
applyToMergePropsCalls(sourceFile, styleMap, matchedClasses)
applyToAllStringLiterals(sourceFile, styleMap)
return sourceFile
}
@@ -288,11 +289,6 @@ function extractCnClasses(str: string) {
return Array.from(matches, (match) => match[0])
}
function extractCnClass(str: string) {
const classes = extractCnClasses(str)
return classes[0] ?? null
}
function removeCnClasses(str: string) {
return str
.replace(/\bcn-[\w-]+\b/g, (match) => {
@@ -606,3 +602,69 @@ function applyClassesToCnCall(
cnCall.replaceWithText(`cn(${updatedArguments.join(", ")})`)
}
}
function isInAlreadyProcessedContext(node: Node): boolean {
let current = node.getParent()
while (current) {
if (Node.isCallExpression(current)) {
const expression = current.getExpression()
if (Node.isIdentifier(expression)) {
const name = expression.getText()
if (name === "cn" || name === "cva") {
return true
}
}
}
if (Node.isJsxAttribute(current)) {
const attrName = current.getNameNode().getText()
if (attrName === "className") {
return true
}
}
current = current.getParent()
}
return false
}
function applyToAllStringLiterals(sourceFile: SourceFile, styleMap: StyleMap) {
sourceFile.forEachDescendant((node) => {
if (!isStringLiteralLike(node)) {
return
}
if (isInAlreadyProcessedContext(node)) {
return
}
const stringValue = node.getLiteralText()
const cnClasses = extractCnClasses(stringValue)
if (cnClasses.length === 0) {
return
}
// Skip allowlisted classes — they are handled at CLI install time.
const classesToInline = cnClasses.filter(
(cnClass) => !ALLOWLIST.has(cnClass)
)
const tailwindClassesToApply = classesToInline
.map((cnClass) => styleMap[cnClass])
.filter((classes): classes is string => Boolean(classes))
if (tailwindClassesToApply.length > 0) {
const mergedClasses = tailwindClassesToApply.join(" ")
const updated = removeCnClasses(mergeClasses(mergedClasses, stringValue))
node.setLiteralValue(updated)
} else {
const updated = removeCnClasses(stringValue)
if (updated !== stringValue) {
node.setLiteralValue(updated)
}
}
})
}