mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-22 04:05:48 +00:00
Compare commits
5 Commits
shadcn@2.6
...
shadcn@2.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae0e5a07b | ||
|
|
431af4f7ff | ||
|
|
c1357982e8 | ||
|
|
92cfb9a30e | ||
|
|
c5d90c718a |
@@ -4,6 +4,25 @@ description: Latest updates and announcements.
|
|||||||
toc: false
|
toc: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## June 2025 - `radix-ui`
|
||||||
|
|
||||||
|
We've added a new command to migrate to the new `radix-ui` package. This command will replace all `@radix-ui/react-*` imports with `radix-ui`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest migrate radix
|
||||||
|
```
|
||||||
|
|
||||||
|
It will automatically update all imports in your `ui` components and install `radix-ui` as a dependency.
|
||||||
|
|
||||||
|
```diff showLineNumbers title="components/ui/alert-dialog.tsx"
|
||||||
|
- import * as AlertDialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
+ import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to test your components and project after running the command.
|
||||||
|
|
||||||
|
**Note:** To update imports for newly added components, run the migration command again.
|
||||||
|
|
||||||
## June 2025 - Calendar Component
|
## June 2025 - Calendar Component
|
||||||
|
|
||||||
We've upgraded the `Calendar` component to the latest version of [React DayPicker](https://daypicker.dev).
|
We've upgraded the `Calendar` component to the latest version of [React DayPicker](https://daypicker.dev).
|
||||||
@@ -42,9 +61,9 @@ Learn more in the thread here: https://x.com/shadcn/status/1917597228513853603
|
|||||||
|
|
||||||
We tagged shadcn 2.5.0 earlier this week. It comes with a pretty cool feature: **resolve anywhere**.
|
We tagged shadcn 2.5.0 earlier this week. It comes with a pretty cool feature: **resolve anywhere**.
|
||||||
|
|
||||||
Registries can now place files anywhere in an app and we’ll properly resolve imports. No need to stick to a fixed file structure. It can even add files outside the registry itself.
|
Registries can now place files anywhere in an app and we'll properly resolve imports. No need to stick to a fixed file structure. It can even add files outside the registry itself.
|
||||||
|
|
||||||
On install, we track all files and perform a multi-pass resolution to correctly handle imports and aliases. It’s fast.
|
On install, we track all files and perform a multi-pass resolution to correctly handle imports and aliases. It's fast.
|
||||||
|
|
||||||
## March 2025 - Cross-framework Route Support
|
## March 2025 - Cross-framework Route Support
|
||||||
|
|
||||||
@@ -61,7 +80,7 @@ What's New:
|
|||||||
- The CLI can now initialize projects with Tailwind v4.
|
- The CLI can now initialize projects with Tailwind v4.
|
||||||
- Full support for the new @theme directive and @theme inline option.
|
- Full support for the new @theme directive and @theme inline option.
|
||||||
- All components are updated for Tailwind v4 and React 19.
|
- All components are updated for Tailwind v4 and React 19.
|
||||||
- We’ve removed the forwardRefs and adjusted the types.
|
- We've removed the forwardRefs and adjusted the types.
|
||||||
- Every primitive now has a data-slot attribute for styling.
|
- Every primitive now has a data-slot attribute for styling.
|
||||||
- We've fixed and cleaned up the style of the components.
|
- We've fixed and cleaned up the style of the components.
|
||||||
- We're deprecating the toast component in favor of sonner.
|
- We're deprecating the toast component in favor of sonner.
|
||||||
@@ -139,7 +158,7 @@ The new CLI is now available. It's a complete rewrite with a lot of new features
|
|||||||
This is a major step towards distributing code that you and your LLMs can access and use.
|
This is a major step towards distributing code that you and your LLMs can access and use.
|
||||||
|
|
||||||
1. First up, the cli now has support for all major React framework out of the box. Next.js, Remix, Vite and Laravel. And when you init into a new app, we update your existing Tailwind files instead of overriding.
|
1. First up, the cli now has support for all major React framework out of the box. Next.js, Remix, Vite and Laravel. And when you init into a new app, we update your existing Tailwind files instead of overriding.
|
||||||
2. A component now ship its own dependencies. Take the accordion for example, it can define its Tailwind keyframes. When you add it to your project, we’ll update your tailwind.config.ts file accordingly.
|
2. A component now ship its own dependencies. Take the accordion for example, it can define its Tailwind keyframes. When you add it to your project, we'll update your tailwind.config.ts file accordingly.
|
||||||
3. You can also install remote components using url. `npx shadcn add https://acme.com/registry/navbar.json`.
|
3. You can also install remote components using url. `npx shadcn add https://acme.com/registry/navbar.json`.
|
||||||
4. We have also improve the init command. It does framework detection and can even init a brand new Next.js app in one command. `npx shadcn init`.
|
4. We have also improve the init command. It does framework detection and can even init a brand new Next.js app in one command. `npx shadcn init`.
|
||||||
5. We have created a new schema that you can use to ship your own component registry. And since it has support for urls, you can even use it to distribute private components.
|
5. We have created a new schema that you can use to ship your own component registry. And since it has support for urls, you can even use it to distribute private components.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const mdxComponents = {
|
|||||||
.replace(/\?/g, "")
|
.replace(/\?/g, "")
|
||||||
.toLowerCase()}
|
.toLowerCase()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading mt-12 scroll-m-28 text-2xl font-medium tracking-tight first:mt-0 lg:mt-20 [&+p]:!mt-4",
|
"font-heading mt-12 scroll-m-28 text-2xl font-medium tracking-tight first:mt-0 lg:mt-20 [&+p]:!mt-4 *:[code]:text-2xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -62,7 +62,7 @@ export const mdxComponents = {
|
|||||||
h3: ({ className, ...props }: React.ComponentProps<"h3">) => (
|
h3: ({ className, ...props }: React.ComponentProps<"h3">) => (
|
||||||
<h3
|
<h3
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading mt-8 scroll-m-28 text-xl font-semibold tracking-tight",
|
"font-heading mt-8 scroll-m-28 text-xl font-semibold tracking-tight *:[code]:text-xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
"recharts": "2.15.1",
|
"recharts": "2.15.1",
|
||||||
"rehype-pretty-code": "^0.14.1",
|
"rehype-pretty-code": "^0.14.1",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"shadcn": "2.6.1",
|
"shadcn": "2.6.3",
|
||||||
"shiki": "^1.10.1",
|
"shiki": "^1.10.1",
|
||||||
"sonner": "^2.0.0",
|
"sonner": "^2.0.0",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
"react-resizable-panels": "^2.0.22",
|
"react-resizable-panels": "^2.0.22",
|
||||||
"react-wrap-balancer": "^0.4.1",
|
"react-wrap-balancer": "^0.4.1",
|
||||||
"recharts": "2.12.7",
|
"recharts": "2.12.7",
|
||||||
"shadcn": "2.6.1",
|
"shadcn": "2.6.3",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"sonner": "^1.2.3",
|
"sonner": "^1.2.3",
|
||||||
"swr": "2.2.6-beta.3",
|
"swr": "2.2.6-beta.3",
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# @shadcn/ui
|
# @shadcn/ui
|
||||||
|
|
||||||
|
## 2.6.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7594](https://github.com/shadcn-ui/ui/pull/7594) [`431af4f7ff294af032c0687b8b655ed6db2e690f`](https://github.com/shadcn-ui/ui/commit/431af4f7ff294af032c0687b8b655ed6db2e690f) Thanks [@shadcn](https://github.com/shadcn)! - fix: semicolon in code style
|
||||||
|
|
||||||
|
## 2.6.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#7586](https://github.com/shadcn-ui/ui/pull/7586) [`c5d90c718a186dd6fd90e022c56089eb569a1c10`](https://github.com/shadcn-ui/ui/commit/c5d90c718a186dd6fd90e022c56089eb569a1c10) Thanks [@shadcn](https://github.com/shadcn)! - add migrate-radix
|
||||||
|
|
||||||
|
- [#7590](https://github.com/shadcn-ui/ui/pull/7590) [`92cfb9a30e976697ab8770f00393bd5325f9a16a`](https://github.com/shadcn-ui/ui/commit/92cfb9a30e976697ab8770f00393bd5325f9a16a) Thanks [@shadcn](https://github.com/shadcn)! - fix flacky tests
|
||||||
|
|
||||||
## 2.6.1
|
## 2.6.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shadcn",
|
"name": "shadcn",
|
||||||
"version": "2.6.1",
|
"version": "2.6.3",
|
||||||
"description": "Add components to your apps.",
|
"description": "Add components to your apps.",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { migrateIcons } from "@/src/migrations/migrate-icons"
|
import { migrateIcons } from "@/src/migrations/migrate-icons"
|
||||||
|
import { migrateRadix } from "@/src/migrations/migrate-radix"
|
||||||
import { preFlightMigrate } from "@/src/preflights/preflight-migrate"
|
import { preFlightMigrate } from "@/src/preflights/preflight-migrate"
|
||||||
import * as ERRORS from "@/src/utils/errors"
|
import * as ERRORS from "@/src/utils/errors"
|
||||||
import { handleError } from "@/src/utils/handle-error"
|
import { handleError } from "@/src/utils/handle-error"
|
||||||
@@ -12,11 +13,16 @@ export const migrations = [
|
|||||||
name: "icons",
|
name: "icons",
|
||||||
description: "migrate your ui components to a different icon library.",
|
description: "migrate your ui components to a different icon library.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "radix",
|
||||||
|
description: "migrate to radix-ui.",
|
||||||
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const migrateOptionsSchema = z.object({
|
export const migrateOptionsSchema = z.object({
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
list: z.boolean(),
|
list: z.boolean(),
|
||||||
|
yes: z.boolean(),
|
||||||
migration: z
|
migration: z
|
||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -40,12 +46,14 @@ export const migrate = new Command()
|
|||||||
process.cwd()
|
process.cwd()
|
||||||
)
|
)
|
||||||
.option("-l, --list", "list all migrations.", false)
|
.option("-l, --list", "list all migrations.", false)
|
||||||
|
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||||
.action(async (migration, opts) => {
|
.action(async (migration, opts) => {
|
||||||
try {
|
try {
|
||||||
const options = migrateOptionsSchema.parse({
|
const options = migrateOptionsSchema.parse({
|
||||||
cwd: path.resolve(opts.cwd),
|
cwd: path.resolve(opts.cwd),
|
||||||
migration,
|
migration,
|
||||||
list: opts.list,
|
list: opts.list,
|
||||||
|
yes: opts.yes,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (options.list || !options.migration) {
|
if (options.list || !options.migration) {
|
||||||
@@ -82,6 +90,10 @@ export const migrate = new Command()
|
|||||||
if (options.migration === "icons") {
|
if (options.migration === "icons") {
|
||||||
await migrateIcons(config)
|
await migrateIcons(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.migration === "radix") {
|
||||||
|
await migrateRadix(config, { yes: options.yes })
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.break()
|
logger.break()
|
||||||
handleError(error)
|
handleError(error)
|
||||||
|
|||||||
1136
packages/shadcn/src/migrations/migrate-radix.test.ts
Normal file
1136
packages/shadcn/src/migrations/migrate-radix.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
400
packages/shadcn/src/migrations/migrate-radix.ts
Normal file
400
packages/shadcn/src/migrations/migrate-radix.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { promises as fs } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { Config } from "@/src/utils/get-config"
|
||||||
|
import { getPackageInfo } from "@/src/utils/get-package-info"
|
||||||
|
import { highlighter } from "@/src/utils/highlighter"
|
||||||
|
import { logger } from "@/src/utils/logger"
|
||||||
|
import { spinner } from "@/src/utils/spinner"
|
||||||
|
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
|
||||||
|
import fg from "fast-glob"
|
||||||
|
import prompts from "prompts"
|
||||||
|
|
||||||
|
function toPascalCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.split("-")
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function processNamedImports(
|
||||||
|
namedImports: string,
|
||||||
|
isTypeOnly: boolean,
|
||||||
|
imports: Array<{ name: string; alias?: string; isType?: boolean }>,
|
||||||
|
packageName: string
|
||||||
|
) {
|
||||||
|
// Clean up multi-line imports.
|
||||||
|
// Remove comments and whitespace.
|
||||||
|
const cleanedImports = namedImports
|
||||||
|
.replace(/\/\/.*$/gm, "")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const namedImportList = cleanedImports
|
||||||
|
.split(",")
|
||||||
|
.map((importItem) => importItem.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
for (const importItem of namedImportList) {
|
||||||
|
const inlineTypeMatch = importItem.match(/^type\s+(\w+)(?:\s+as\s+(\w+))?$/)
|
||||||
|
const aliasMatch = importItem.match(/^(\w+)\s+as\s+(\w+)$/)
|
||||||
|
|
||||||
|
if (inlineTypeMatch) {
|
||||||
|
// Inline type: "type DialogProps" or "type DialogProps as Props"
|
||||||
|
const importName = inlineTypeMatch[1]
|
||||||
|
const importAlias = inlineTypeMatch[2]
|
||||||
|
|
||||||
|
if (packageName === "slot" && importName === "Slot" && !importAlias) {
|
||||||
|
imports.push({
|
||||||
|
name: "Slot",
|
||||||
|
alias: "SlotPrimitive",
|
||||||
|
isType: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
imports.push({
|
||||||
|
name: importName,
|
||||||
|
alias: importAlias,
|
||||||
|
isType: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (aliasMatch) {
|
||||||
|
// Regular import with alias: "Root as DialogRoot"
|
||||||
|
const importName = aliasMatch[1]
|
||||||
|
const importAlias = aliasMatch[2]
|
||||||
|
|
||||||
|
if (
|
||||||
|
packageName === "slot" &&
|
||||||
|
importName === "Slot" &&
|
||||||
|
importAlias === "Slot"
|
||||||
|
) {
|
||||||
|
imports.push({
|
||||||
|
name: "Slot",
|
||||||
|
alias: "SlotPrimitive",
|
||||||
|
isType: isTypeOnly,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
imports.push({
|
||||||
|
name: importName,
|
||||||
|
alias: importAlias,
|
||||||
|
isType: isTypeOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple import: "Root"
|
||||||
|
// Special handling for Slot: always alias it as SlotPrimitive
|
||||||
|
if (packageName === "slot" && importItem === "Slot") {
|
||||||
|
imports.push({
|
||||||
|
name: "Slot",
|
||||||
|
alias: "SlotPrimitive",
|
||||||
|
isType: isTypeOnly,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
imports.push({
|
||||||
|
name: importItem,
|
||||||
|
isType: isTypeOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateRadix(
|
||||||
|
config: Config,
|
||||||
|
options: { yes?: boolean } = {}
|
||||||
|
) {
|
||||||
|
if (!config.resolvedPaths.ui) {
|
||||||
|
throw new Error(
|
||||||
|
"We could not find a valid `ui` path in your `components.json` file. Please ensure you have a valid `ui` path in your `components.json` file."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiPath = config.resolvedPaths.ui
|
||||||
|
const files = await fg("**/*.{js,ts,jsx,tsx}", {
|
||||||
|
cwd: uiPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!options.yes) {
|
||||||
|
const { confirm } = await prompts({
|
||||||
|
type: "confirm",
|
||||||
|
name: "confirm",
|
||||||
|
initial: true,
|
||||||
|
message: `We will migrate ${highlighter.info(
|
||||||
|
files.length
|
||||||
|
)} files in ${highlighter.info(
|
||||||
|
`./${path.relative(config.resolvedPaths.cwd, uiPath)}`
|
||||||
|
)} to ${highlighter.info("radix-ui")}. Continue?`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
logger.info("Migration cancelled.")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationSpinner = spinner(`Migrating imports...`)?.start()
|
||||||
|
const foundPackages = new Set<string>()
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
migrationSpinner.text = `Migrating ${file}...`
|
||||||
|
|
||||||
|
const filePath = path.join(uiPath, file)
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||||
|
|
||||||
|
const { content, replacedPackages } = await migrateRadixFile(fileContent)
|
||||||
|
|
||||||
|
// Track which packages we found
|
||||||
|
replacedPackages.forEach((pkg) => foundPackages.add(pkg))
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, content)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
migrationSpinner.succeed("Migrating imports.")
|
||||||
|
|
||||||
|
// Update package.json dependencies
|
||||||
|
const packageSpinner = spinner(`Updating package.json...`)?.start()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageJson = getPackageInfo(config.resolvedPaths.cwd, false)
|
||||||
|
|
||||||
|
if (!packageJson) {
|
||||||
|
packageSpinner.fail("Could not read package.json")
|
||||||
|
logger.warn(
|
||||||
|
"Could not update package.json. You may need to manually replace @radix-ui/react-* packages with radix-ui"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundPackagesArray = Array.from(foundPackages)
|
||||||
|
|
||||||
|
// Remove packages from both dependencies and devDependencies if found in source files
|
||||||
|
const dependencyTypes = ["dependencies", "devDependencies"] as const
|
||||||
|
for (const depType of dependencyTypes) {
|
||||||
|
if (packageJson[depType]) {
|
||||||
|
for (const pkg of foundPackagesArray) {
|
||||||
|
if (packageJson[depType]![pkg]) {
|
||||||
|
delete packageJson[depType]![pkg]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add radix-ui if we found any Radix packages.
|
||||||
|
if (foundPackagesArray.length > 0) {
|
||||||
|
if (!packageJson.dependencies) {
|
||||||
|
packageJson.dependencies = {}
|
||||||
|
}
|
||||||
|
packageJson.dependencies["radix-ui"] = "latest"
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(
|
||||||
|
config.resolvedPaths.cwd,
|
||||||
|
"package.json"
|
||||||
|
)
|
||||||
|
await fs.writeFile(
|
||||||
|
packageJsonPath,
|
||||||
|
JSON.stringify(packageJson, null, 2) + "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
packageSpinner.succeed(`Updated package.json.`)
|
||||||
|
|
||||||
|
// Install radix-ui dependency.
|
||||||
|
await updateDependencies(["radix-ui"], [], config, { silent: false })
|
||||||
|
} else {
|
||||||
|
packageSpinner.succeed("No packages found in source files.")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
packageSpinner.fail("Failed to update package.json")
|
||||||
|
logger.warn(
|
||||||
|
"You may need to manually replace @radix-ui/react-* packages with radix-ui"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateRadixFile(
|
||||||
|
content: string
|
||||||
|
): Promise<{ content: string; replacedPackages: string[] }> {
|
||||||
|
// Enhanced regex to handle type-only imports, but exclude react-icons
|
||||||
|
// Also capture optional semicolon at the end
|
||||||
|
const radixImportPattern =
|
||||||
|
/import\s+(?:(type)\s+)?(?:\*\s+as\s+(\w+)|{([^}]+)})\s+from\s+(["'])@radix-ui\/react-([^"']+)\4(;?)/g
|
||||||
|
|
||||||
|
const imports: Array<{ name: string; alias?: string; isType?: boolean }> = []
|
||||||
|
const linesToRemove: string[] = []
|
||||||
|
const replacedPackages: string[] = []
|
||||||
|
let quoteStyle = '"' // Default to double quotes
|
||||||
|
let hasSemicolon = false // Track if any import had a semicolon
|
||||||
|
|
||||||
|
let result = content
|
||||||
|
let match
|
||||||
|
|
||||||
|
// Find all Radix imports
|
||||||
|
while ((match = radixImportPattern.exec(content)) !== null) {
|
||||||
|
const [
|
||||||
|
fullMatch,
|
||||||
|
typeKeyword,
|
||||||
|
namespaceAlias,
|
||||||
|
namedImports,
|
||||||
|
quote,
|
||||||
|
packageName,
|
||||||
|
semicolon,
|
||||||
|
] = match
|
||||||
|
|
||||||
|
// Skip react-icons package and any sub-paths (like react-icons/dist/types)
|
||||||
|
if (packageName === "icons" || packageName.startsWith("icons/")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
linesToRemove.push(fullMatch)
|
||||||
|
|
||||||
|
// Use the quote style and semicolon style from the first import
|
||||||
|
if (linesToRemove.length === 1) {
|
||||||
|
quoteStyle = quote
|
||||||
|
hasSemicolon = semicolon === ";"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which package we're replacing
|
||||||
|
replacedPackages.push(`@radix-ui/react-${packageName}`)
|
||||||
|
|
||||||
|
const isTypeOnly = Boolean(typeKeyword)
|
||||||
|
|
||||||
|
if (namespaceAlias) {
|
||||||
|
// Handle namespace imports: import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
const componentName = toPascalCase(packageName)
|
||||||
|
imports.push({
|
||||||
|
name: componentName,
|
||||||
|
alias: namespaceAlias,
|
||||||
|
isType: isTypeOnly,
|
||||||
|
})
|
||||||
|
} else if (namedImports) {
|
||||||
|
// Handle named imports: import { Root, Trigger } from "@radix-ui/react-dialog"
|
||||||
|
// or import type { DialogProps } from "@radix-ui/react-dialog"
|
||||||
|
// or import { type DialogProps, Root } from "@radix-ui/react-dialog"
|
||||||
|
|
||||||
|
processNamedImports(namedImports, isTypeOnly, imports, packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imports.length === 0) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
replacedPackages: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates.
|
||||||
|
// Considering name, alias, and type status.
|
||||||
|
const uniqueImports = imports.filter(
|
||||||
|
(importName, index, self) =>
|
||||||
|
index ===
|
||||||
|
self.findIndex(
|
||||||
|
(i) =>
|
||||||
|
i.name === importName.name &&
|
||||||
|
i.alias === importName.alias &&
|
||||||
|
i.isType === importName.isType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the unified import with preserved quote style and type annotations
|
||||||
|
const importList = uniqueImports
|
||||||
|
.map((imp) => {
|
||||||
|
const typePrefix = imp.isType ? "type " : ""
|
||||||
|
return imp.alias
|
||||||
|
? `${typePrefix}${imp.name} as ${imp.alias}`
|
||||||
|
: `${typePrefix}${imp.name}`
|
||||||
|
})
|
||||||
|
.join(", ")
|
||||||
|
|
||||||
|
const unifiedImport = `import { ${importList} } from ${quoteStyle}radix-ui${quoteStyle}${
|
||||||
|
hasSemicolon ? ";" : ""
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Replace first import with unified import, remove the rest
|
||||||
|
result = linesToRemove.reduce((acc, line, index) => {
|
||||||
|
return acc.replace(line, index === 0 ? unifiedImport : "")
|
||||||
|
}, result)
|
||||||
|
|
||||||
|
// Clean up extra blank lines
|
||||||
|
result = result.replace(/\n\s*\n\s*\n/g, "\n\n")
|
||||||
|
|
||||||
|
// Handle special case for Slot usage transformation
|
||||||
|
// Now that we import { Slot as SlotPrimitive }, we need to:
|
||||||
|
// 1. Transform: const Comp = asChild ? Slot : [ANYTHING] -> const Comp = asChild ? SlotPrimitive.Slot : [ANYTHING]
|
||||||
|
// 2. Transform: React.ComponentProps<typeof Slot> -> React.ComponentProps<typeof SlotPrimitive.Slot>
|
||||||
|
const hasSlotImport = uniqueImports.some(
|
||||||
|
(imp) => imp.name === "Slot" && imp.alias === "SlotPrimitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasSlotImport) {
|
||||||
|
// Find all lines that are NOT import lines to avoid transforming the import statement itself
|
||||||
|
const lines = result.split("\n")
|
||||||
|
const transformedLines = lines.map((line) => {
|
||||||
|
// Skip import lines
|
||||||
|
if (line.trim().startsWith("import ")) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
let transformedLine = line
|
||||||
|
|
||||||
|
// Handle all Slot references in one comprehensive pass
|
||||||
|
// Use placeholders to avoid double replacements
|
||||||
|
|
||||||
|
// First, mark specific patterns with placeholders
|
||||||
|
transformedLine = transformedLine.replace(
|
||||||
|
/\b(asChild\s*\?\s*)Slot(\s*:)/g,
|
||||||
|
"$1__SLOT_PLACEHOLDER__$2"
|
||||||
|
)
|
||||||
|
|
||||||
|
transformedLine = transformedLine.replace(
|
||||||
|
/\bReact\.ComponentProps<typeof\s+Slot>/g,
|
||||||
|
"React.ComponentProps<typeof __SLOT_PLACEHOLDER__>"
|
||||||
|
)
|
||||||
|
|
||||||
|
transformedLine = transformedLine.replace(
|
||||||
|
/\bComponentProps<typeof\s+Slot>/g,
|
||||||
|
"ComponentProps<typeof __SLOT_PLACEHOLDER__>"
|
||||||
|
)
|
||||||
|
|
||||||
|
transformedLine = transformedLine.replace(
|
||||||
|
/(<\/?)Slot(\s*\/?>)/g,
|
||||||
|
"$1__SLOT_PLACEHOLDER__$2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle any other standalone Slot usage
|
||||||
|
transformedLine = transformedLine.replace(
|
||||||
|
/\bSlot\b/g,
|
||||||
|
(match, offset, string) => {
|
||||||
|
// Don't transform if it's inside quotes
|
||||||
|
const beforeMatch = string.substring(0, offset)
|
||||||
|
const openQuotes = (beforeMatch.match(/"/g) || []).length
|
||||||
|
const openSingleQuotes = (beforeMatch.match(/'/g) || []).length
|
||||||
|
|
||||||
|
// If we're inside quotes, don't transform
|
||||||
|
if (openQuotes % 2 !== 0 || openSingleQuotes % 2 !== 0) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return "__SLOT_PLACEHOLDER__"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finally, replace all placeholders with SlotPrimitive.Slot
|
||||||
|
transformedLine = transformedLine.replace(
|
||||||
|
/__SLOT_PLACEHOLDER__/g,
|
||||||
|
"SlotPrimitive.Slot"
|
||||||
|
)
|
||||||
|
|
||||||
|
return transformedLine
|
||||||
|
})
|
||||||
|
|
||||||
|
result = transformedLines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicate packages
|
||||||
|
const uniqueReplacedPackages = Array.from(new Set(replacedPackages))
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: result,
|
||||||
|
replacedPackages: uniqueReplacedPackages,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import { fetchRegistry } from "@/src/registry/api"
|
import { fetchRegistry } from "@/src/registry/api"
|
||||||
|
import { spinner } from "@/src/utils/spinner"
|
||||||
import { execa } from "execa"
|
import { execa } from "execa"
|
||||||
import fs from "fs-extra"
|
import fs from "fs-extra"
|
||||||
import prompts from "prompts"
|
import prompts from "prompts"
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
type MockInstance,
|
||||||
|
} from "vitest"
|
||||||
|
|
||||||
import { TEMPLATES, createProject } from "./create-project"
|
import { TEMPLATES, createProject } from "./create-project"
|
||||||
|
|
||||||
@@ -14,16 +23,85 @@ vi.mock("@/src/registry/api")
|
|||||||
vi.mock("@/src/utils/get-package-manager", () => ({
|
vi.mock("@/src/utils/get-package-manager", () => ({
|
||||||
getPackageManager: vi.fn().mockResolvedValue("npm"),
|
getPackageManager: vi.fn().mockResolvedValue("npm"),
|
||||||
}))
|
}))
|
||||||
|
vi.mock("@/src/utils/spinner")
|
||||||
|
vi.mock("@/src/utils/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
break: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
describe("createProject", () => {
|
describe("createProject", () => {
|
||||||
|
let mockExit: MockInstance
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
// Reset all fs mocks
|
||||||
vi.mocked(fs.access).mockResolvedValue(undefined)
|
vi.mocked(fs.access).mockResolvedValue(undefined)
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||||
|
vi.mocked(fs.ensureDir).mockResolvedValue(undefined)
|
||||||
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||||
|
vi.mocked(fs.move).mockResolvedValue(undefined)
|
||||||
|
vi.mocked(fs.remove).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// Mock execa to resolve immediately without actual execution
|
||||||
|
vi.mocked(execa).mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
exitCode: 0,
|
||||||
|
signal: undefined,
|
||||||
|
signalDescription: undefined,
|
||||||
|
command: "",
|
||||||
|
escapedCommand: "",
|
||||||
|
failed: false,
|
||||||
|
timedOut: false,
|
||||||
|
isCanceled: false,
|
||||||
|
killed: false,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Mock fetch for monorepo template
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Reset prompts mock
|
||||||
|
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
|
||||||
|
|
||||||
|
// Reset registry mock
|
||||||
|
vi.mocked(fetchRegistry).mockResolvedValue([])
|
||||||
|
|
||||||
|
// Mock spinner function
|
||||||
|
const mockSpinner = {
|
||||||
|
start: vi.fn().mockReturnThis(),
|
||||||
|
succeed: vi.fn().mockReturnThis(),
|
||||||
|
fail: vi.fn().mockReturnThis(),
|
||||||
|
stop: vi.fn().mockReturnThis(),
|
||||||
|
text: "",
|
||||||
|
prefixText: "",
|
||||||
|
suffixText: "",
|
||||||
|
color: "cyan" as const,
|
||||||
|
indent: 0,
|
||||||
|
spinner: "dots" as const,
|
||||||
|
isSpinning: false,
|
||||||
|
interval: 100,
|
||||||
|
stream: process.stderr,
|
||||||
|
clear: vi.fn(),
|
||||||
|
render: vi.fn(),
|
||||||
|
frame: vi.fn(),
|
||||||
|
stopAndPersist: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}
|
||||||
|
vi.mocked(spinner).mockReturnValue(mockSpinner as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
|
mockExit?.mockRestore()
|
||||||
|
delete (global as any).fetch
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should create a Next.js project with default options", async () => {
|
it("should create a Next.js project with default options", async () => {
|
||||||
@@ -84,10 +162,13 @@ describe("createProject", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should throw error if project path already exists", async () => {
|
it("should throw error if project path already exists", async () => {
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
// Mock fs.existsSync to return true only for the specific package.json path
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||||
|
return path.toString().includes("existing-app/package.json")
|
||||||
|
})
|
||||||
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "existing-app" })
|
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "existing-app" })
|
||||||
|
|
||||||
const mockExit = vi
|
mockExit = vi
|
||||||
.spyOn(process, "exit")
|
.spyOn(process, "exit")
|
||||||
.mockImplementation(() => undefined as never)
|
.mockImplementation(() => undefined as never)
|
||||||
|
|
||||||
@@ -103,7 +184,7 @@ describe("createProject", () => {
|
|||||||
vi.mocked(fs.access).mockRejectedValue(new Error("Permission denied"))
|
vi.mocked(fs.access).mockRejectedValue(new Error("Permission denied"))
|
||||||
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
|
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
|
||||||
|
|
||||||
const mockExit = vi
|
mockExit = vi
|
||||||
.spyOn(process, "exit")
|
.spyOn(process, "exit")
|
||||||
.mockImplementation(() => undefined as never)
|
.mockImplementation(() => undefined as never)
|
||||||
|
|
||||||
|
|||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -322,7 +322,7 @@ importers:
|
|||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.1
|
version: 6.0.1
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: 2.6.1
|
specifier: 2.6.3
|
||||||
version: link:../../packages/shadcn
|
version: link:../../packages/shadcn
|
||||||
shiki:
|
shiki:
|
||||||
specifier: ^1.10.1
|
specifier: ^1.10.1
|
||||||
@@ -602,7 +602,7 @@ importers:
|
|||||||
specifier: 2.12.7
|
specifier: 2.12.7
|
||||||
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: 2.6.1
|
specifier: 2.6.3
|
||||||
version: link:../../packages/shadcn
|
version: link:../../packages/shadcn
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.32.6
|
specifier: ^0.32.6
|
||||||
|
|||||||
Reference in New Issue
Block a user