diff --git a/.changeset/cold-dancers-fix.md b/.changeset/cold-dancers-fix.md new file mode 100644 index 0000000000..887e35cf92 --- /dev/null +++ b/.changeset/cold-dancers-fix.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +handle cn-\* class transformation in non-className contexts diff --git a/packages/shadcn/src/styles/transform-style-map.test.ts b/packages/shadcn/src/styles/transform-style-map.test.ts index 880e6bd46b..c72faa8b8b 100644 --- a/packages/shadcn/src/styles/transform-style-map.test.ts +++ b/packages/shadcn/src/styles/transform-style-map.test.ts @@ -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 ( + + ) +} +` + + 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 ( + + ) + } + " + `) + }) + + 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", + } + " + `) + }) }) diff --git a/packages/shadcn/src/styles/transform-style-map.ts b/packages/shadcn/src/styles/transform-style-map.ts index da43772f8a..914ea819cf 100644 --- a/packages/shadcn/src/styles/transform-style-map.ts +++ b/packages/shadcn/src/styles/transform-style-map.ts @@ -41,6 +41,7 @@ export const transformStyleMap: TransformerStyle = 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) + } + } + }) +}