Merge branch 'main' into shadcn/init-preset

This commit is contained in:
shadcn
2026-02-12 11:37:50 +04:00
16 changed files with 497 additions and 254 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
fix handling of nested aschild transforms

View File

@@ -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."
}
]
]

View File

@@ -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"
}

View File

@@ -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"
},

View File

@@ -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"
}

View File

@@ -90,6 +90,13 @@
"description": "Radix-style React primitives for AI chat with adapters for AI SDK, LangGraph, Mastra, and custom backends.",
"logo": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 32 32\"><rect width=\"32\" height=\"32\" fill=\"var(--foreground)\" /><g transform=\"translate(4,4)\" fill=\"var(--foreground)\" stroke=\"var(--background)\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2z\" /><path d=\"M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1\" /></g></svg>"
},
{
"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": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='var(--foreground)' d='M12 1 21.526 6.5v11L12 23 2.474 17.5v-11z'/><circle cx='12' cy='12' r='5' fill='var(--background)'/></svg>"
},
{
"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": "<svg xmlns='http://www.w3.org/2000/svg' width='101' height='100' fill='none' viewBox='0 0 101 100'><g fill='var(--foreground)' clip-path='url(#a)'><path fill-rule='evenodd' d='M67.167 3.846H33.833c-16.285 0-29.487 13.202-29.487 29.487v33.334c0 16.285 13.202 29.487 29.487 29.487h33.334c16.285 0 29.487-13.202 29.487-29.487V33.333c0-16.285-13.202-29.487-29.487-29.487M33.833 0C15.423 0 .5 14.924.5 33.333v33.334C.5 85.077 15.424 100 33.833 100h33.334c18.41 0 33.333-14.924 33.333-33.333V33.333C100.5 14.923 85.576 0 67.167 0z' clip-rule='evenodd' opacity='.2'/><path d='M70.636 34.396a4.698 4.698 0 1 0 0-9.396 4.698 4.698 0 0 0 0 9.396'/><path fill-rule='evenodd' d='M25.672 56.879V29.698a4.698 4.698 0 1 1 9.396 0v27.181a8.725 8.725 0 0 0 8.725 8.725h13.423a8.725 8.725 0 0 0 8.724-8.725V43.59a4.698 4.698 0 1 1 9.396 0v13.29c0 10.008-8.113 18.12-18.12 18.12H43.793c-10.008 0-18.121-8.113-18.121-18.121' clip-rule='evenodd'/></g><defs><clipPath id='a'><path fill='var(--background)' d='M0 0h101v100H0z'/></clipPath></defs></svg>"
},
{
@@ -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": "<svg xmlns='http://www.w3.org/2000/svg' id='Layer_2' data-name='Layer 2' viewBox='0 0 463.07 463.07'><defs><style>.cls-1{fill:var(--muted);stroke:var(--foreground);stroke-miterlimit:10;stroke-width:12px}</style></defs><g id='Layer_1-2' data-name='Layer 1'><path d='M459.07 231.54c0 41.45-11.07 80.3-30.44 113.76H231.54c6.42 0 12.72-.52 18.86-1.57 53.85-8.99 94.91-55.81 94.91-112.2s-41.05-103.21-94.91-112.2c-6.14-1.05-12.45-1.57-18.86-1.57V4c26.62 0 52.15 4.58 75.88 12.97a225 225 0 0 1 37.89 17.47 227.1 227.1 0 0 1 56.88 46.63 225 225 0 0 1 26.44 36.7c19.37 33.47 30.44 72.31 30.44 113.77Z' class='cls-1'/><path d='M345.31 231.54c0 56.38-41.05 103.21-94.91 112.2a113 113 0 0 1-18.86 1.57c-62.82 0-113.77-50.95-113.77-113.76s50.95-113.77 113.77-113.77c6.42 0 12.72.52 18.86 1.57 53.85 8.99 94.91 55.81 94.91 112.2Z' class='cls-1'/><path d='M231.53 4v113.77c-62.82 0-113.77 50.95-113.77 113.77V345.3H4V231.54c0-41.46 11.07-80.3 30.44-113.77 19.97-34.54 48.78-63.35 83.32-83.33C151.24 15.08 190.08 4 231.53 4Z' class='cls-1'/></g></svg>"
},
{
"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": "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'><path d='M0 0h512v512H0z' fill='#222222'/><path d='M252.128 99.616q3.552 0.016 7.088 -0.016c25.168 -0.032 50.576 6.56 69.776 23.408l2.304 1.824c13.152 10.784 20.256 26.096 24.704 42.176l0.608 1.952c0.56 2.96 0.384 6.032 0.384 9.056h-44l-4 -13.008c-9.216 -15.072 -21.792 -24.688 -38.992 -29.088 -18.672 -3.984 -39.104 -2.016 -55.328 8.176 -10.352 7.328 -16.416 17.424 -18.688 29.904a64 64 0 0 0 -0.368 8.256v2.224c0.272 12.272 5.104 21.984 13.872 30.448 7.488 6.224 16.16 10.16 25.216 13.456l2.48 0.912c8.336 2.992 16.8 5.456 25.312 7.856 57.472 16.224 57.472 16.224 74.464 31.856l2.352 2.112c5.664 5.344 9.84 11.104 13.648 17.888l1.216 2.096c8.48 16.576 8.72 41.984 3.184 59.376 -7.6 19.216 -18.224 33.824 -35.392 45.52l-2.304 1.6c-12.512 8.128 -26.96 12.4 -41.664 14.4l-2.016 0.288c-14.816 1.888 -31.424 2.144 -45.984 -1.28l-2.096 -0.464c-25.472 -5.632 -49.136 -18.096 -63.776 -40.416A78.4 78.4 0 0 1 152 328h43.008l3.008 12c4.624 14.112 13.488 25.12 26.56 32.304 16.8 7.728 40.816 9.12 58.304 3.024 15.056 -6.368 26.592 -13.152 33.2 -28.864 4.48 -12.976 4.608 -26.848 -1.072 -39.472a48 48 0 0 0 -7.008 -9.008l-1.952 -2.08c-15.568 -15.136 -40 -19.376 -60.176 -25.072 -67.12 -18.928 -67.12 -18.928 -81.84 -44.56a88 88 0 0 1 -7.408 -66.32c3.52 -10.608 8.864 -20.672 16.4 -28.976l1.904 -2.192c9.968 -10.736 22.496 -17.712 36.096 -22.816l2.304 -0.928c12.496 -4.64 25.632 -5.568 38.816 -5.472' fill='#FDFDFD'/><path d='M511.008 471.008h1.008v41.008h-41.008a112 112 0 0 1 9.696 -4.192c13.968 -5.824 22.432 -16.112 28.304 -29.824a64 64 0 0 0 2 -7.008' fill='#000000'/><path d='M0 471.008c1.616 3.216 2.896 6.336 4.192 9.696 5.808 13.968 16.096 22.432 29.808 28.304 2.336 0.816 4.576 1.456 7.008 2v1.008H0z' fill='#000000'/><path d='M471.008 0h41.008v41.008a112 112 0 0 1 -4.192 -9.68c-5.824 -14 -16.112 -22.464 -29.824 -28.32a64 64 0 0 0 -7.008 -2z' fill='#000000'/><path d='M0 0h41.008a112 112 0 0 1 -9.68 4.192C17.328 10 8.864 20.288 3.008 34a64 64 0 0 0 -2 7.008H0z' fill='#000000'/></svg>"
},
{
"name": "@satoriui",
"homepage": "https://satoriui.site",

View File

@@ -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: [],

View File

@@ -90,20 +90,19 @@ export const add = new Command()
}
let itemType: z.infer<typeof registryItemTypeSchema> | 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,

View File

@@ -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,
})

View File

@@ -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",
})

View File

@@ -38,7 +38,6 @@ export async function addComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
baseStyle?: boolean
registryHeaders?: Record<string, Record<string, string>>
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
}

View File

@@ -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 (
<DialogTrigger render={<a href="#" />} nativeButton={false}>Open Dialog</DialogTrigger>
<DialogTrigger render={<a href="#" />}>Open Dialog</DialogTrigger>
)
}"
`)
@@ -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 (
<Collapsible asChild>
<SidebarMenuButton asChild>
<a href="#">Home</a>
</SidebarMenuButton>
</Collapsible>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<Collapsible render={<SidebarMenuButton render={<a href="#" />} />}>Home</Collapsible>
)
}"
`)
})
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 (
<DialogTrigger asChild>
<Button asChild>
<a href="#">Open</a>
</Button>
</DialogTrigger>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<DialogTrigger render={<Button render={<a href="#" />} nativeButton={false} />}>Open</DialogTrigger>
)
}"
`)
})
test("transforms nested with sibling asChild elements", async () => {
expect(
await transform(
{
filename: "test.tsx",
raw: `import * as React from "react"
export function Component() {
return (
<div>
<Collapsible asChild>
<SidebarMenuButton asChild>
<a href="#">Home</a>
</SidebarMenuButton>
</Collapsible>
<DialogTrigger asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
</div>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<div>
<Collapsible render={<SidebarMenuButton render={<a href="#" />} />}>Home</Collapsible>
<DialogTrigger render={<Button variant="outline" />}>Edit</DialogTrigger>
</div>
)
}"
`)
})
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 (
<Collapsible asChild>
<SidebarMenuButton asChild>
<Icon className="size-4" />
</SidebarMenuButton>
</Collapsible>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<Collapsible render={<SidebarMenuButton render={<Icon className="size-4" />} />}></Collapsible>
)
}"
`)
})
test("transforms triple-nested asChild", async () => {
expect(
await transform(
{
filename: "test.tsx",
raw: `import * as React from "react"
export function Component() {
return (
<TooltipTrigger asChild>
<Collapsible asChild>
<SidebarMenuButton asChild>
<a href="#">Home</a>
</SidebarMenuButton>
</Collapsible>
</TooltipTrigger>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<TooltipTrigger render={<Collapsible render={<SidebarMenuButton render={<a href="#" />} />} />}>Home</TooltipTrigger>
)
}"
`)
})
})
describe("idempotency", () => {
test("running twice produces same output", async () => {
const input = `import * as React from "react"

View File

@@ -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}</${info.parentTagName}>`
// 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.parentTagName}>`
info.parentElement.replaceWithText(newElementText)
}
}
return sourceFile

View File

@@ -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<typeof registryItemTailwindSchema>["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<typeof registryItemTailwindSchema>["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<typeof registryItemCssVarsSchema>
) {
@@ -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---" }))
}

View File

@@ -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({

View File

@@ -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";