mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
Compare commits
1 Commits
shadcn@4.8
...
fix/cn-cla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be151c6d9 |
5
.changeset/cold-dancers-fix.md
Normal file
5
.changeset/cold-dancers-fix.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": patch
|
||||
---
|
||||
|
||||
handle cn-\* class transformation in non-className contexts
|
||||
@@ -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",
|
||||
}
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user