diff --git a/.changeset/tiny-moons-hang.md b/.changeset/tiny-moons-hang.md new file mode 100644 index 0000000000..1ebdc1f862 --- /dev/null +++ b/.changeset/tiny-moons-hang.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +fix handling of nested aschild transforms diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json index 880d92d3e7..81feea0656 100644 --- a/apps/v4/public/r/registries.json +++ b/apps/v4/public/r/registries.json @@ -77,6 +77,12 @@ "url": "https://r.assistant-ui.com/{name}.json", "description": "Radix-style React primitives for AI chat with adapters for AI SDK, LangGraph, Mastra, and custom backends." }, + { + "name": "@tool-ui", + "homepage": "https://www.tool-ui.com", + "url": "https://www.tool-ui.com/r/{name}.json", + "description": "Open source React components for rendering AI tool call widgets and rich assistant outputs." + }, { "name": "@better-upload", "homepage": "https://better-upload.com", @@ -428,8 +434,8 @@ { "name": "@reui", "homepage": "https://reui.io", - "url": "https://reui.io/r/{name}.json", - "description": "Open-source collection of UI components and animated effects built with React, Typescript, Tailwind CSS, and Motion. Pairs beautifully with shadcn/ui." + "url": "https://reui.io/r/{style}/{name}.json", + "description": "Free & open-source library of 1,000+ components and patterns to 10x your productivity in shadcn projects." }, { "name": "@scrollxui", @@ -455,6 +461,12 @@ "url": "https://roiui.com/r/{name}.json", "description": "Roi UI is a library that offers UI components and blocks built with Base UI primitives. Some blocks and components use motion (framer). Everything is open-source and will be forever." }, + { + "name": "@slide-cn", + "homepage": "https://slide-cn.com", + "url": "https://slide-cn.com/r/{name}.json", + "description": "A component library to build slide decks using code." + }, { "name": "@satoriui", "homepage": "https://satoriui.site", @@ -749,4 +761,4 @@ "url": "https://sona-ui.vercel.app/r/{name}.json", "description": "A modern UI component library built with React and TailwindCSS to help you build beautiful and accessible web applications faster." } -] \ No newline at end of file +] diff --git a/apps/v4/public/r/styles/new-york-v4/index.json b/apps/v4/public/r/styles/new-york-v4/index.json index ae7203d2fe..69f13cef90 100644 --- a/apps/v4/public/r/styles/new-york-v4/index.json +++ b/apps/v4/public/r/styles/new-york-v4/index.json @@ -16,7 +16,16 @@ "files": [], "cssVars": {}, "css": { - "@import \"shadcn/tailwind.css\"": {} + "@import \"tw-animate-css\"": {}, + "@import \"shadcn/tailwind.css\"": {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {} + }, + "body": { + "@apply bg-background text-foreground": {} + } + } }, "type": "registry:style" } \ No newline at end of file diff --git a/apps/v4/public/r/styles/new-york-v4/registry.json b/apps/v4/public/r/styles/new-york-v4/registry.json index 7b72e315e2..2db23814c3 100644 --- a/apps/v4/public/r/styles/new-york-v4/registry.json +++ b/apps/v4/public/r/styles/new-york-v4/registry.json @@ -19,7 +19,16 @@ "files": [], "cssVars": {}, "css": { - "@import \"shadcn/tailwind.css\"": {} + "@import \"tw-animate-css\"": {}, + "@import \"shadcn/tailwind.css\"": {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {} + }, + "body": { + "@apply bg-background text-foreground": {} + } + } }, "type": "registry:style" }, @@ -40,7 +49,16 @@ "files": [], "cssVars": {}, "css": { - "@import \"shadcn/tailwind.css\"": {} + "@import \"tw-animate-css\"": {}, + "@import \"shadcn/tailwind.css\"": {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {} + }, + "body": { + "@apply bg-background text-foreground": {} + } + } }, "type": "registry:style" }, diff --git a/apps/v4/public/r/styles/new-york-v4/style.json b/apps/v4/public/r/styles/new-york-v4/style.json index 452a4322f8..9d8bb6576c 100644 --- a/apps/v4/public/r/styles/new-york-v4/style.json +++ b/apps/v4/public/r/styles/new-york-v4/style.json @@ -16,7 +16,16 @@ "files": [], "cssVars": {}, "css": { - "@import \"shadcn/tailwind.css\"": {} + "@import \"tw-animate-css\"": {}, + "@import \"shadcn/tailwind.css\"": {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {} + }, + "body": { + "@apply bg-background text-foreground": {} + } + } }, "type": "registry:style" } \ No newline at end of file diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json index f50c7e3872..bbbc04896f 100644 --- a/apps/v4/registry/directory.json +++ b/apps/v4/registry/directory.json @@ -90,6 +90,13 @@ "description": "Radix-style React primitives for AI chat with adapters for AI SDK, LangGraph, Mastra, and custom backends.", "logo": "" }, + { + "name": "@tool-ui", + "homepage": "https://www.tool-ui.com", + "url": "https://www.tool-ui.com/r/{name}.json", + "description": "Open source React components for rendering AI tool call widgets and rich assistant outputs.", + "logo": "" + }, { "name": "@better-upload", "homepage": "https://better-upload.com", @@ -499,8 +506,8 @@ { "name": "@reui", "homepage": "https://reui.io", - "url": "https://reui.io/r/{name}.json", - "description": "Open-source collection of UI components and animated effects built with React, Typescript, Tailwind CSS, and Motion. Pairs beautifully with shadcn/ui.", + "url": "https://reui.io/r/{style}/{name}.json", + "description": "Free & open-source library of 1,000+ components and patterns to 10x your productivity in shadcn projects.", "logo": "" }, { @@ -531,6 +538,13 @@ "description": "Roi UI is a library that offers UI components and blocks built with Base UI primitives. Some blocks and components use motion (framer). Everything is open-source and will be forever.", "logo": "" }, + { + "name": "@slide-cn", + "homepage": "https://slide-cn.com", + "url": "https://slide-cn.com/r/{name}.json", + "description": "A component library to build slide decks using code.", + "logo": "" + }, { "name": "@satoriui", "homepage": "https://satoriui.site", diff --git a/apps/v4/registry/new-york-v4/registry.ts b/apps/v4/registry/new-york-v4/registry.ts index d9a5b99690..ce71657675 100644 --- a/apps/v4/registry/new-york-v4/registry.ts +++ b/apps/v4/registry/new-york-v4/registry.ts @@ -26,7 +26,16 @@ const NEW_YORK_V4_STYLE = { devDependencies: ["tw-animate-css", "shadcn"], registryDependencies: ["utils"], css: { + '@import "tw-animate-css"': {}, '@import "shadcn/tailwind.css"': {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {}, + }, + body: { + "@apply bg-background text-foreground": {}, + }, + }, }, cssVars: {}, files: [], diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts index ebfb821a5d..e2cc919418 100644 --- a/packages/shadcn/src/commands/add.ts +++ b/packages/shadcn/src/commands/add.ts @@ -90,20 +90,19 @@ export const add = new Command() } let itemType: z.infer | undefined - let shouldInstallBaseStyle = true + let shouldInstallStyleIndex = true if (components.length > 0) { const [registryItem] = await getRegistryItems([components[0]], { config: initialConfig, }) itemType = registryItem?.type - shouldInstallBaseStyle = - itemType !== "registry:theme" && itemType !== "registry:style" + shouldInstallStyleIndex = + itemType !== "registry:theme" && + itemType !== "registry:style" && + itemType !== "registry:base" if (isUniversalRegistryItem(registryItem)) { - await addComponents(components, initialConfig, { - ...options, - baseStyle: shouldInstallBaseStyle, - }) + await addComponents(components, initialConfig, options) return } @@ -180,8 +179,8 @@ export const add = new Command() isNewProject: false, srcDir: options.srcDir, cssVariables: options.cssVariables, - baseStyle: shouldInstallBaseStyle, - baseColor: shouldInstallBaseStyle ? undefined : "neutral", + installStyleIndex: shouldInstallStyleIndex, + baseColor: shouldInstallStyleIndex ? undefined : "neutral", components: options.components, }) initHasRun = true @@ -216,8 +215,8 @@ export const add = new Command() isNewProject: true, srcDir: options.srcDir, cssVariables: options.cssVariables, - baseStyle: shouldInstallBaseStyle, - baseColor: shouldInstallBaseStyle ? undefined : "neutral", + installStyleIndex: shouldInstallStyleIndex, + baseColor: shouldInstallStyleIndex ? undefined : "neutral", components: options.components, }) initHasRun = true @@ -244,10 +243,7 @@ export const add = new Command() config = updatedConfig if (!initHasRun) { - await addComponents(options.components, config, { - ...options, - baseStyle: shouldInstallBaseStyle, - }) + await addComponents(options.components, config, options) } // If we're adding a single component and it's from the v0 registry, diff --git a/packages/shadcn/src/commands/create.ts b/packages/shadcn/src/commands/create.ts index 6316c9383f..1df60f1d7c 100644 --- a/packages/shadcn/src/commands/create.ts +++ b/packages/shadcn/src/commands/create.ts @@ -205,7 +205,7 @@ export const create = new Command() rtl: opts.rtl, template, baseColor, - baseStyle: false, + installStyleIndex: false, registryBaseConfig, skipPreflight: false, }) @@ -219,7 +219,6 @@ export const create = new Command() components.push("direction") } await addComponents(components, config, { - baseStyle: false, silent: true, overwrite: true, }) diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 6e77070be8..dd79047107 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -111,7 +111,7 @@ export const initOptionsSchema = z.object({ ).join("', '")}'`, } ), - baseStyle: z.boolean(), + installStyleIndex: z.boolean(), // Config from registry:base item to merge into components.json. registryBaseConfig: rawConfigSchema.deepPartial().optional(), }) @@ -261,6 +261,7 @@ export const init = new Command() isNewProject: false, components, ...opts, + installStyleIndex: opts.baseStyle, }) await loadEnvFiles(options.cwd) @@ -332,8 +333,8 @@ export const init = new Command() // Store config to be merged into components.json later. options.registryBaseConfig = item.config } - options.baseStyle = - item.extends === "none" ? false : options.baseStyle + options.installStyleIndex = + item.extends === "none" ? false : options.installStyleIndex } if (item?.type === "registry:style") { @@ -342,14 +343,13 @@ export const init = new Command() options.baseColor = "neutral" // If the style extends none, we don't want to install the base style. - options.baseStyle = - item.extends === "none" ? false : options.baseStyle + options.installStyleIndex = + item.extends === "none" ? false : options.installStyleIndex } } // If --no-base-style, we don't want to prompt for a base color either. - // The style will extend or override it. - if (!options.baseStyle) { + if (!options.installStyleIndex) { options.baseColor = "neutral" } @@ -457,7 +457,7 @@ export async function runInit( // Why index? Because when style is true, we read style from components.json and fetch that. // i.e new-york from components.json then fetch /styles/new-york/index. // TODO: Fix this so that we can extend any style i.e --style=new-york. - ...(options.baseStyle ? ["index"] : []), + ...(options.installStyleIndex ? ["index"] : []), ...(options.components ?? []), ] @@ -530,7 +530,6 @@ export async function runInit( // Init will always overwrite files. overwrite: true, silent: options.silent, - baseStyle: options.baseStyle, isNewProject: options.isNewProject || projectInfo?.framework.name === "next-app", }) diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index c24d2c1924..e0cca97913 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -38,7 +38,6 @@ export async function addComponents( overwrite?: boolean silent?: boolean isNewProject?: boolean - baseStyle?: boolean registryHeaders?: Record> path?: string } @@ -47,7 +46,6 @@ export async function addComponents( overwrite: false, silent: false, isNewProject: false, - baseStyle: true, ...options, } @@ -74,11 +72,10 @@ async function addProjectComponents( overwrite?: boolean silent?: boolean isNewProject?: boolean - baseStyle?: boolean path?: string } ) { - if (!options.baseStyle && !components.length) { + if (!components.length) { return } @@ -117,7 +114,6 @@ async function addProjectComponents( tailwindVersion, tailwindConfig: tree.tailwind?.config, overwriteCssVars, - initIndex: options.baseStyle, }) // Add CSS updater @@ -157,11 +153,10 @@ async function addWorkspaceComponents( silent?: boolean isNewProject?: boolean isRemote?: boolean - baseStyle?: boolean path?: string } ) { - if (!options.baseStyle && !components.length) { + if (!components.length) { return } diff --git a/packages/shadcn/src/utils/transformers/transform-aschild.test.ts b/packages/shadcn/src/utils/transformers/transform-aschild.test.ts index 5005f6e421..61fcb25e06 100644 --- a/packages/shadcn/src/utils/transformers/transform-aschild.test.ts +++ b/packages/shadcn/src/utils/transformers/transform-aschild.test.ts @@ -63,7 +63,7 @@ export function Component() { }) describe("DialogTrigger with non-Button child", () => { - test("transforms asChild to render prop with nativeButton={false}", async () => { + test("transforms asChild to render prop without nativeButton", async () => { expect( await transform( { @@ -86,7 +86,7 @@ export function Component() { export function Component() { return ( - } nativeButton={false}>Open Dialog + }>Open Dialog ) }" `) @@ -186,7 +186,7 @@ export function Component() { }) }) - describe("component with Link child", () => { + describe("Button with Link child", () => { test("transforms asChild to render prop with nativeButton={false}", async () => { expect( await transform( @@ -390,6 +390,173 @@ export function Component() { }) }) + describe("nested asChild", () => { + test("transforms inner asChild first, then outer", async () => { + expect( + await transform( + { + filename: "test.tsx", + raw: `import * as React from "react" + +export function Component() { + return ( + + + Home + + + ) +}`, + config: testConfig, + }, + [transformAsChild] + ) + ).toMatchInlineSnapshot(` + "import * as React from "react" + + export function Component() { + return ( + } />}>Home + ) + }" + `) + }) + + test("adds nativeButton={false} only on nested Button", async () => { + expect( + await transform( + { + filename: "test.tsx", + raw: `import * as React from "react" + +export function Component() { + return ( + + + + ) +}`, + config: testConfig, + }, + [transformAsChild] + ) + ).toMatchInlineSnapshot(` + "import * as React from "react" + + export function Component() { + return ( + } nativeButton={false} />}>Open + ) + }" + `) + }) + + test("transforms nested with sibling asChild elements", async () => { + expect( + await transform( + { + filename: "test.tsx", + raw: `import * as React from "react" + +export function Component() { + return ( +
+ + + Home + + + + + +
+ ) +}`, + config: testConfig, + }, + [transformAsChild] + ) + ).toMatchInlineSnapshot(` + "import * as React from "react" + + export function Component() { + return ( +
+ } />}>Home + }>Edit +
+ ) + }" + `) + }) + + test("transforms nested with self-closing inner child", async () => { + expect( + await transform( + { + filename: "test.tsx", + raw: `import * as React from "react" + +export function Component() { + return ( + + + + + + ) +}`, + config: testConfig, + }, + [transformAsChild] + ) + ).toMatchInlineSnapshot(` + "import * as React from "react" + + export function Component() { + return ( + } />}> + ) + }" + `) + }) + + test("transforms triple-nested asChild", async () => { + expect( + await transform( + { + filename: "test.tsx", + raw: `import * as React from "react" + +export function Component() { + return ( + + + + Home + + + + ) +}`, + config: testConfig, + }, + [transformAsChild] + ) + ).toMatchInlineSnapshot(` + "import * as React from "react" + + export function Component() { + return ( + } />} />}>Home + ) + }" + `) + }) + }) + describe("idempotency", () => { test("running twice produces same output", async () => { const input = `import * as React from "react" diff --git a/packages/shadcn/src/utils/transformers/transform-aschild.ts b/packages/shadcn/src/utils/transformers/transform-aschild.ts index 543deff3c3..6be86d2d2e 100644 --- a/packages/shadcn/src/utils/transformers/transform-aschild.ts +++ b/packages/shadcn/src/utils/transformers/transform-aschild.ts @@ -27,118 +27,142 @@ export const transformAsChild: Transformer = async ({ sourceFile, config }) => { return sourceFile } - // Collect all transformations first, then apply them in reverse order. - // This prevents issues with invalidated nodes when modifying the tree. - const transformations: TransformInfo[] = [] + // Process asChild elements iteratively, starting from leaf-level elements. + // Each iteration transforms only elements with no asChild descendants, + // ensuring inner transforms complete before outer ones read the tree. + const MAX_ITERATIONS = 10 + for (let i = 0; i < MAX_ITERATIONS; i++) { + const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement) - // Find all JSX elements with asChild attribute. - const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement) - - for (const jsxElement of jsxElements) { - const openingElement = jsxElement.getOpeningElement() - const asChildAttr = openingElement.getAttribute("asChild") - - if (!asChildAttr) { - continue - } - - const parentTagName = openingElement.getTagNameNode().getText() - const children = jsxElement.getJsxChildren() - - // Find the first JSX element child (skip whitespace/text). - const childElement = children.find( - (child) => - child.getKind() === SyntaxKind.JsxElement || - child.getKind() === SyntaxKind.JsxSelfClosingElement + // Find all JSX elements with asChild attribute. + const asChildElements = jsxElements.filter((el) => + el.getOpeningElement().getAttribute("asChild") ) - if (!childElement) { - // No child element found, just remove asChild. - asChildAttr.remove() - continue + if (asChildElements.length === 0) { + break } - // Get child element info. - let childTagName: string - let childProps: string - let childChildren: string - - if (childElement.getKind() === SyntaxKind.JsxSelfClosingElement) { - const selfClosing = childElement.asKindOrThrow( - SyntaxKind.JsxSelfClosingElement + // Filter to leaf-only: elements with no asChild descendants. + const leafElements = asChildElements.filter((el) => { + const descendants = el.getDescendantsOfKind(SyntaxKind.JsxElement) + return !descendants.some((d) => + d.getOpeningElement().getAttribute("asChild") ) - childTagName = selfClosing.getTagNameNode().getText() - childProps = selfClosing - .getAttributes() - .map((attr) => attr.getText()) - .join(" ") - childChildren = "" - } else { - const jsxChild = childElement.asKindOrThrow(SyntaxKind.JsxElement) - const openingEl = jsxChild.getOpeningElement() - childTagName = openingEl.getTagNameNode().getText() - childProps = openingEl - .getAttributes() - .map((attr) => attr.getText()) - .join(" ") - // Get the children's text content. - childChildren = jsxChild - .getJsxChildren() - .map((c) => c.getText()) - .join("") - } - - // Determine if we need nativeButton={false}. - // Add it when the child element is a non-button element. - const needsNativeButton = - ELEMENTS_REQUIRING_NATIVE_BUTTON_FALSE.includes(childTagName) - - transformations.push({ - parentElement: jsxElement, - parentTagName, - childTagName, - childProps, - childChildren, - needsNativeButton, }) - } - // Apply transformations in reverse order to preserve node validity. - for (const info of transformations.reverse()) { - const openingElement = info.parentElement.getOpeningElement() - const closingElement = info.parentElement.getClosingElement() + // Collect all transformations first, then apply them in reverse order. + // This prevents issues with invalidated nodes when modifying the tree. + const transformations: TransformInfo[] = [] - // Get existing attributes (excluding asChild). - const existingAttrs = openingElement - .getAttributes() - .filter((attr) => { - if (attr.getKind() === SyntaxKind.JsxAttribute) { - const jsxAttr = attr.asKindOrThrow(SyntaxKind.JsxAttribute) - return jsxAttr.getNameNode().getText() !== "asChild" - } - return true + for (const jsxElement of leafElements) { + const openingElement = jsxElement.getOpeningElement() + const asChildAttr = openingElement.getAttribute("asChild") + + if (!asChildAttr) { + continue + } + + const parentTagName = openingElement.getTagNameNode().getText() + const children = jsxElement.getJsxChildren() + + // Find the first JSX element child (skip whitespace/text). + const childElement = children.find( + (child) => + child.getKind() === SyntaxKind.JsxElement || + child.getKind() === SyntaxKind.JsxSelfClosingElement + ) + + if (!childElement) { + // No child element found, just remove asChild. + asChildAttr.remove() + continue + } + + // Get child element info. + let childTagName: string + let childProps: string + let childChildren: string + + if (childElement.getKind() === SyntaxKind.JsxSelfClosingElement) { + const selfClosing = childElement.asKindOrThrow( + SyntaxKind.JsxSelfClosingElement + ) + childTagName = selfClosing.getTagNameNode().getText() + childProps = selfClosing + .getAttributes() + .map((attr) => attr.getText()) + .join(" ") + childChildren = "" + } else { + const jsxChild = childElement.asKindOrThrow(SyntaxKind.JsxElement) + const openingEl = jsxChild.getOpeningElement() + childTagName = openingEl.getTagNameNode().getText() + childProps = openingEl + .getAttributes() + .map((attr) => attr.getText()) + .join(" ") + // Get the children's text content. + childChildren = jsxChild + .getJsxChildren() + .map((c) => c.getText()) + .join("") + } + + // Determine if we need nativeButton={false}. + // Only add it on Button when the child is a non-button element. + const needsNativeButton = + parentTagName === "Button" && + ELEMENTS_REQUIRING_NATIVE_BUTTON_FALSE.includes(childTagName) + + transformations.push({ + parentElement: jsxElement, + parentTagName, + childTagName, + childProps, + childChildren, + needsNativeButton, }) - .map((attr) => attr.getText()) - .join(" ") - - // Build the render prop value. - const renderValue = info.childProps - ? `{<${info.childTagName} ${info.childProps} />}` - : `{<${info.childTagName} />}` - - // Build new attributes. - let newAttrs = existingAttrs ? `${existingAttrs} ` : "" - newAttrs += `render=${renderValue}` - if (info.needsNativeButton) { - newAttrs += ` nativeButton={false}` } - // Build the new element text. - const newChildren = info.childChildren.trim() ? `${info.childChildren}` : "" + // Apply transformations in reverse order to preserve node validity. + for (const info of transformations.reverse()) { + const openingElement = info.parentElement.getOpeningElement() - const newElementText = `<${info.parentTagName} ${newAttrs}>${newChildren}` + // Get existing attributes (excluding asChild). + const existingAttrs = openingElement + .getAttributes() + .filter((attr) => { + if (attr.getKind() === SyntaxKind.JsxAttribute) { + const jsxAttr = attr.asKindOrThrow(SyntaxKind.JsxAttribute) + return jsxAttr.getNameNode().getText() !== "asChild" + } + return true + }) + .map((attr) => attr.getText()) + .join(" ") - info.parentElement.replaceWithText(newElementText) + // Build the render prop value. + const renderValue = info.childProps + ? `{<${info.childTagName} ${info.childProps} />}` + : `{<${info.childTagName} />}` + + // Build new attributes. + let newAttrs = existingAttrs ? `${existingAttrs} ` : "" + newAttrs += `render=${renderValue}` + if (info.needsNativeButton) { + newAttrs += ` nativeButton={false}` + } + + // Build the new element text. + const newChildren = info.childChildren.trim() + ? `${info.childChildren}` + : "" + + const newElementText = `<${info.parentTagName} ${newAttrs}>${newChildren}` + + info.parentElement.replaceWithText(newElementText) + } } return sourceFile diff --git a/packages/shadcn/src/utils/updaters/update-css-vars.ts b/packages/shadcn/src/utils/updaters/update-css-vars.ts index f14af41638..fc1aea4109 100644 --- a/packages/shadcn/src/utils/updaters/update-css-vars.ts +++ b/packages/shadcn/src/utils/updaters/update-css-vars.ts @@ -5,7 +5,6 @@ import { registryItemTailwindSchema, } from "@/src/schema" import { Config } from "@/src/utils/get-config" -import { getPackageInfo } from "@/src/utils/get-package-info" import { TailwindVersion } from "@/src/utils/get-project-info" import { highlighter } from "@/src/utils/highlighter" import { spinner } from "@/src/utils/spinner" @@ -21,7 +20,6 @@ export async function updateCssVars( options: { cleanupDefaultNextStyles?: boolean overwriteCssVars?: boolean - initIndex?: boolean silent?: boolean tailwindVersion?: TailwindVersion tailwindConfig?: z.infer["config"] @@ -36,7 +34,6 @@ export async function updateCssVars( silent: false, tailwindVersion: "v3", overwriteCssVars: false, - initIndex: true, ...options, } const cssFilepath = config.resolvedPaths.tailwindCss @@ -56,7 +53,6 @@ export async function updateCssVars( tailwindVersion: options.tailwindVersion, tailwindConfig: options.tailwindConfig, overwriteCssVars: options.overwriteCssVars, - initIndex: options.initIndex, }) await fs.writeFile(cssFilepath, output, "utf8") cssVarsSpinner.succeed() @@ -71,13 +67,11 @@ export async function transformCssVars( tailwindVersion?: TailwindVersion tailwindConfig?: z.infer["config"] overwriteCssVars?: boolean - initIndex?: boolean } = { cleanupDefaultNextStyles: false, tailwindVersion: "v3", tailwindConfig: undefined, overwriteCssVars: false, - initIndex: false, } ) { options = { @@ -85,7 +79,6 @@ export async function transformCssVars( tailwindVersion: "v3", tailwindConfig: undefined, overwriteCssVars: false, - initIndex: false, ...options, } @@ -98,18 +91,6 @@ export async function transformCssVars( if (options.tailwindVersion === "v4") { plugins = [] - // Only add tw-animate-css if project does not have tailwindcss-animate - if (config.resolvedPaths?.cwd) { - const packageInfo = getPackageInfo(config.resolvedPaths.cwd) - if ( - !packageInfo?.dependencies?.["tailwindcss-animate"] && - !packageInfo?.devDependencies?.["tailwindcss-animate"] && - options.initIndex - ) { - plugins.push(addCustomImport({ params: "tw-animate-css" })) - } - } - plugins.push(addCustomVariant({ params: "dark (&:is(.dark *))" })) if (options.cleanupDefaultNextStyles) { @@ -130,12 +111,6 @@ export async function transformCssVars( } } - if (config.tailwind.cssVariables && options.initIndex) { - plugins.push( - updateBaseLayerPlugin({ tailwindVersion: options.tailwindVersion }) - ) - } - const result = await postcss(plugins).process(input, { from: undefined, }) @@ -151,81 +126,6 @@ export async function transformCssVars( return output } -function updateBaseLayerPlugin({ - tailwindVersion, -}: { - tailwindVersion?: TailwindVersion -}) { - return { - postcssPlugin: "update-base-layer", - Once(root: Root) { - const requiredRules = [ - { - selector: "*", - apply: - tailwindVersion === "v4" - ? "border-border outline-ring/50" - : "border-border", - }, - { selector: "body", apply: "bg-background text-foreground" }, - ] - - let baseLayer = root.nodes.find( - (node): node is AtRule => - node.type === "atrule" && - node.name === "layer" && - node.params === "base" && - requiredRules.every(({ selector, apply }) => - node.nodes?.some( - (rule): rule is Rule => - rule.type === "rule" && - rule.selector === selector && - rule.nodes.some( - (applyRule): applyRule is AtRule => - applyRule.type === "atrule" && - applyRule.name === "apply" && - applyRule.params === apply - ) - ) - ) - ) as AtRule | undefined - - if (!baseLayer) { - baseLayer = postcss.atRule({ - name: "layer", - params: "base", - raws: { semicolon: true, between: " ", before: "\n" }, - }) - root.append(baseLayer) - root.insertBefore(baseLayer, postcss.comment({ text: "---break---" })) - } - - requiredRules.forEach(({ selector, apply }) => { - const existingRule = baseLayer?.nodes?.find( - (node): node is Rule => - node.type === "rule" && node.selector === selector - ) - - if (!existingRule) { - baseLayer?.append( - postcss.rule({ - selector, - nodes: [ - postcss.atRule({ - name: "apply", - params: apply, - raws: { semicolon: true, before: "\n " }, - }), - ], - raws: { semicolon: true, between: " ", before: "\n " }, - }) - ) - } - }) - }, - } -} - function updateCssVarsPlugin( cssVars: z.infer ) { @@ -662,13 +562,13 @@ function addCustomImport({ params }: { params: string }) { node.type === "atrule" && node.name === "import" ) - // Find custom variant node (to ensure we insert before it) + // Find custom variant node (to ensure we insert before it). const customVariantNode = root.nodes.find( (node): node is AtRule => node.type === "atrule" && node.name === "custom-variant" ) - // Check if our specific import already exists + // Check if our specific import already exists. const hasImport = importNodes.some( (node) => node.params.replace(/["']/g, "") === params ) @@ -681,18 +581,18 @@ function addCustomImport({ params }: { params: string }) { }) if (importNodes.length > 0) { - // If there are existing imports, add after the last import + // If there are existing imports, add after the last import. const lastImport = importNodes[importNodes.length - 1] root.insertAfter(lastImport, importNode) } else if (customVariantNode) { - // If no imports but has custom-variant, insert before it + // If no imports but has custom-variant, insert before it. root.insertBefore(customVariantNode, importNode) root.insertBefore( customVariantNode, postcss.comment({ text: "---break---" }) ) } else { - // If no imports and no custom-variant, insert at the start + // If no imports and no custom-variant, insert at the start. root.prepend(importNode) root.insertAfter(importNode, postcss.comment({ text: "---break---" })) } diff --git a/packages/shadcn/src/utils/updaters/update-css.ts b/packages/shadcn/src/utils/updaters/update-css.ts index 13b2c74357..37243225f5 100644 --- a/packages/shadcn/src/utils/updaters/update-css.ts +++ b/packages/shadcn/src/utils/updaters/update-css.ts @@ -472,12 +472,23 @@ function processRule(parent: Root | AtRule, selector: string, properties: any) { const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/) if (atRuleMatch) { const [, atRuleName, atRuleParams] = atRuleMatch - const atRule = postcss.atRule({ - name: atRuleName, - params: atRuleParams, - raws: { semicolon: true, before: "\n " }, - }) - rule.append(atRule) + + // Check if this at-rule already exists in the rule. + const existingAtRule = rule.nodes?.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === atRuleName && + node.params === atRuleParams + ) + + if (!existingAtRule) { + const atRule = postcss.atRule({ + name: atRuleName, + params: atRuleParams, + raws: { semicolon: true, before: "\n " }, + }) + rule.append(atRule) + } } } else if (typeof value === "string") { const decl = postcss.decl({ diff --git a/packages/shadcn/test/utils/updaters/update-css.test.ts b/packages/shadcn/test/utils/updaters/update-css.test.ts index c1d3a6fb5f..3470409922 100644 --- a/packages/shadcn/test/utils/updaters/update-css.test.ts +++ b/packages/shadcn/test/utils/updaters/update-css.test.ts @@ -853,6 +853,82 @@ describe("transformCss", () => { `) }) + test("should add base layer styles from registry:style css field", async () => { + const input = `@import "tailwindcss";` + + // This is the exact shape from the registry:style index item. + const result = await transformCss(input, { + '@import "tw-animate-css"': {}, + '@import "shadcn/tailwind.css"': {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {}, + }, + body: { + "@apply bg-background text-foreground": {}, + }, + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @import "tw-animate-css"; + @import "shadcn/tailwind.css"; + + @layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + }" + `) + }) + + test("should not duplicate base layer styles if already present", async () => { + const input = `@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +}` + + const result = await transformCss(input, { + '@import "tw-animate-css"': {}, + '@import "shadcn/tailwind.css"': {}, + "@layer base": { + "*": { + "@apply border-border outline-ring/50": {}, + }, + body: { + "@apply bg-background text-foreground": {}, + }, + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @import "tw-animate-css"; + @import "shadcn/tailwind.css"; + + @layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + }" + `) + }) + test("should replace existing keyframes instead of duplicating", async () => { const input = `@import "tailwindcss";