Compare commits

...

25 Commits

Author SHA1 Message Date
github-actions[bot]
7443edcfb0 chore(release): version packages (#7719)
* chore(release): version packages

* deps: lock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2025-07-07 20:52:36 +04:00
shadcn
9d9a33be52 fix: margin 2025-07-07 15:10:32 +04:00
shadcn
d544a7f7a5 feat: refactor registryDependencies resolution (#7720)
* feat(shadcn): refactor registry dependencies resolution

* chore: changeset

* fix

* style: fix some code style
2025-07-01 17:56:50 +04:00
shadcn
48fe0d709f feat(shadcn): add file support (#7717)
* feat(shadcn): add file support

* fix: format

* fix: types

* feat(shadcn): update init and add description

* docs: update docs for cli

* chore: add changeset
2025-07-01 17:06:17 +04:00
Kitsune
ed244ea0b5 fix(cli): detect vinxi-based frameworks (@tanstack/start, SolidStart, ...) (#6330)
* fix(cli): detect vinxi-based frameworks

* chore: add changeset

---------

Co-authored-by: shadcn <m@shadcn.com>
2025-07-01 16:58:07 +04:00
Wolfr
b8fede1742 docs(v4): link to obra figma kit (#7643) 2025-06-30 11:45:49 +04:00
github-actions[bot]
84d6c83bad chore(release): version packages (#7626)
* chore(release): version packages

* chore(release): version packages

* deps: update lock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2025-06-18 16:01:20 +04:00
xabierlameiro.com
5b8ee41511 fix(cli): correct function name typo unnsetSpreadElements to unsetSpreadElements (#7609)
* fix(cli): correct function name typo unnsetSpreadElements to unsetSpreadElements

* chore: add changeset

---------

Co-authored-by: shadcn <m@shadcn.com>
2025-06-18 11:44:59 +04:00
shadcn
7c3d34cdc9 chore: fix changeset (#7640)
* fix(shadcn): update plugin handling

* style(shadcn): format fix

* docs(www): add docs for plugins

* chore: add changeset
2025-06-18 11:29:23 +04:00
shadcn
56c4c83511 fix(shadcn): update plugin handling (#7632)
* fix(shadcn): update plugin handling

* style(shadcn): format fix

* docs(www): add docs for plugins
2025-06-18 11:03:36 +04:00
shadcn
2821cb0e39 chore: move cli to deprecated (#7631) 2025-06-17 12:32:10 +04:00
Wolfr
3c87402de2 Add newly available Figma kit to docs (#7604)
Co-authored-by: shadcn <m@shadcn.com>
2025-06-16 16:21:26 +04:00
xabierlameiro.com
20a88e1f15 fix(components): resolve duplicate id conflict in calendar-24 component (#7611)
* fix(components): resolve duplicate id conflict in calendar-24 component

- Changed Button id from 'date' to 'date-picker'
- Changed Input id from 'time' to 'time-picker'
- Updated corresponding Label htmlFor attributes to match new unique IDs

Fixes #7561

* chore: rebuild registry after calendar-24 fixes
2025-06-16 16:13:51 +04:00
Zach Nugent
cb19ab8464 feat(shadcn): add support for updating dependencies with expo-cli for RN compatibility (#7540)
* feat(shadcn): add support for updating dependencies with expo-cli for RN compatibility

* feat(shadcn): add expo as a framework

* fix: update the contributing command for registry

* refactor(shadcn): update dependencies install functionality

* chore: changeset

---------

Co-authored-by: shadcn <m@shadcn.com>
2025-06-16 16:05:41 +04:00
github-actions[bot]
cf1851ca09 chore(release): version packages (#7625)
* chore(release): version packages

* deps: update lock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2025-06-16 15:45:23 +04:00
Manuel Schiller
c86c27a2ff fix TanStack Start detection (#7601)
* fix tanstack start detection

* chore: changeset

---------

Co-authored-by: shadcn <m@shadcn.com>
2025-06-16 15:24:41 +04:00
Gaëtan H
8847126c65 chore(vscode): set custom Tailwind config path for monorepo UI (#7618)
Co-authored-by: shadcn <m@shadcn.com>
2025-06-16 15:18:27 +04:00
shadcn
65350857a4 ci: fix stale bot (#7624) 2025-06-16 15:02:53 +04:00
shadcn
40c7473c7e fix(www): update open-in-v0-cta.tsx 2025-06-14 06:20:16 +04:00
Taesu
4698ee960f chore: update react-day-picker version to match updated calendar component (#7585)
Co-authored-by: shadcn <m@shadcn.com>
2025-06-12 15:44:40 +04:00
github-actions[bot]
2ae0e5a07b chore(release): version packages (#7595)
* chore(release): version packages

* deps: install

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2025-06-11 20:06:18 +04:00
shadcn
431af4f7ff fix(shadcn): semicolon in code style (#7594)
* fix(shadcn): handle semicolon in code style

* chore: changeset

* fix: format
2025-06-11 19:54:04 +04:00
github-actions[bot]
c1357982e8 chore(release): version packages (#7591)
* chore(release): version packages

* deps: update

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2025-06-11 13:57:06 +04:00
shadcn
92cfb9a30e fix(shadcn): flaky create-project tests (#7590)
* fix(shadcn): flaky create-project tests

* fix

* fix
2025-06-11 13:50:35 +04:00
shadcn
c5d90c718a feat: add migrate radix command (#7586)
* feat(shadcn): add migrate-radix command

* feat(shadcn): fix and test edge cases

* test(shadcn): add tests for all primitives

* fix(shadcn): edge cases and add yes option

* fix

* chore(shadcn): add changeset

* style: fix code styles

* docs: update changelog

* fix: format

* feat: update changelog

* fix: format
2025-06-11 13:20:47 +04:00
57 changed files with 2836 additions and 217 deletions

View File

@@ -18,15 +18,15 @@ jobs:
repo-token: ${{ secrets.STALE_TOKEN }}
ascending: true
days-before-issue-close: 7
days-before-issue-stale: 365 # ~2 years
days-before-issue-stale: 365
days-before-pr-stale: -1
days-before-pr-close: -1
remove-issue-stale-when-updated: true
stale-issue-label: "stale?"
exempt-issue-labels: "roadmap,next,bug"
stale-issue-message: "This issue has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless theres further input. If you believe this issue is still relevant, please leave a comment or provide updated details. Thank you."
close-issue-message: "This issue has been automatically closed due to one year of inactivity. If youre still experiencing a similar problem or have additional details to share, please open a new issue following our current issue template. Your updated report helps us investigate and address concerns more efficiently. Thank you for your understanding!"
operations-per-run: 300 # 1 operation per 100 issues, the rest is to label/comment/close
exempt-issue-labels: "roadmap,next"
stale-issue-message: "This issue has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless theres further input. If you believe this issue is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)"
close-issue-message: "This issue has been automatically closed due to one year of inactivity. If youre still experiencing a similar problem or have additional details to share, please open a new issue following our current issue template. Your updated report helps us investigate and address concerns more efficiently. Thank you for your understanding! (This is an automated message)"
operations-per-run: 300
- uses: actions/stale@v9
id: pr-state
name: "Mark stale PRs, close stale PRs"
@@ -36,10 +36,10 @@ jobs:
days-before-issue-close: -1
days-before-issue-stale: -1
days-before-pr-close: 7
days-before-pr-stale: 365 # PRs with no activity in over 90 days will be marked as stale
days-before-pr-stale: 365
remove-pr-stale-when-updated: true
exempt-pr-labels: "roadmap,nex,awaiting-approval,work-in-progress"
exempt-pr-labels: "roadmap,next,bug"
stale-pr-label: "stale?"
stale-pr-message: "This PR has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless theres further input. If you believe this PR is still relevant, please leave a comment or provide updated details. Thank you."
close-pr-message: "This PR has been automatically closed due to one year of inactivity. Thank you for your understanding!"
operations-per-run: 300 # 1 operation per 100 issues, the rest is to label/comment/close
stale-pr-message: "This PR has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless theres further input. If you believe this PR is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)"
close-pr-message: "This PR has been automatically closed due to one year of inactivity. Thank you for your understanding! (This is an automated message)"
operations-per-run: 300

View File

@@ -98,7 +98,7 @@ To run the CLI locally, you can follow the workflow:
1. Start by running the registry (main site) to make sure the components are up to date:
```bash
pnpm www:dev
pnpm v4:dev
```
2. Run the development script for the CLI:

2
apps/v4/.env.example Normal file
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_V0_URL=https://v0.dev
NEXT_PUBLIC_APP_URL=http://localhost:4000

1
apps/v4/.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -15,7 +15,7 @@ export function Callout({
return (
<Alert
className={cn(
"bg-surface text-surface-foreground mt-6 w-auto border-none md:-mx-4",
"bg-surface text-surface-foreground mt-6 w-auto border-none md:-mx-1",
className
)}
{...props}

View File

@@ -22,7 +22,7 @@ export function CodeCollapsibleWrapper({
<Collapsible
open={isOpened}
onOpenChange={setIsOpened}
className={cn("group/collapsible relative md:-mx-4", className)}
className={cn("group/collapsible relative md:-mx-1", className)}
{...props}
>
<CollapsibleTrigger asChild>

View File

@@ -51,7 +51,7 @@ export function ComponentPreviewTabs({
</Tabs>
<div
data-tab={tab}
className="data-[tab=code]:border-code relative rounded-lg border md:-mx-4"
className="data-[tab=code]:border-code relative rounded-lg border md:-mx-1"
>
<div
data-slot="preview"

View File

@@ -34,7 +34,7 @@ export function ComponentPreview({
if (type === "block") {
return (
<div className="relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-4">
<div className="relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1">
<Image
src={`/r/styles/new-york-v4/${name}-light.png`}
alt={name}

View File

@@ -13,7 +13,7 @@ export function OpenInV0Cta({ className }: React.ComponentProps<"div">) {
Deploy your shadcn/ui app on Vercel
</div>
<div className="text-muted-foreground">
Trusted by OpenAI, Sonos, Chick-fil-A, and more.
Trusted by OpenAI, Sonos, Adobe, and more.
</div>
<div className="text-muted-foreground">
Vercel provides tools and infrastructure to deploy apps and features at

View File

@@ -4,6 +4,25 @@ description: Latest updates and announcements.
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
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**.
Registries can now place files anywhere in an app and well 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. Its 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
@@ -61,7 +80,7 @@ What's New:
- The CLI can now initialize projects with Tailwind v4.
- Full support for the new @theme directive and @theme inline option.
- All components are updated for Tailwind v4 and React 19.
- Weve 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.
- We've fixed and cleaned up the style of the components.
- 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.
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, well 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`.
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.

View File

@@ -21,15 +21,14 @@ Usage: shadcn init [options] [components...]
initialize your project and install dependencies
Arguments:
components the components to add or a url to the component.
components name, url or local path to component
Options:
-t, --template <template> the template to use. (next, next-monorepo)
-b, --base-color <base-color> the base color to use. (neutral, gray, zinc, stone, slate)
-y, --yes skip confirmation prompt. (default: true)
-f, --force force overwrite of existing configuration. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory. (default:
"/Users/shadcn/Code/shadcn/ui/packages/shadcn")
-c, --cwd <cwd> the working directory. defaults to the current directory.
-s, --silent mute output. (default: false)
--src-dir use the src directory when creating a new project. (default: false)
--no-src-dir do not use the src directory when creating a new project.
@@ -54,12 +53,12 @@ Usage: shadcn add [options] [components...]
add a component to your project
Arguments:
components the components to add or a url to the component.
components name, url or local path to component
Options:
-y, --yes skip confirmation prompt. (default: false)
-o, --overwrite overwrite existing files. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory. (default: "/Users/shadcn/Desktop")
-c, --cwd <cwd> the working directory. defaults to the current directory.
-a, --all add all available components (default: false)
-p, --path <path> the path to add the component to.
-s, --silent mute output. (default: false)
@@ -92,8 +91,7 @@ Arguments:
Options:
-o, --output <path> destination directory for json files (default: "./public/r")
-c, --cwd <cwd> the working directory. defaults to the current directory. (default:
"/Users/shadcn/Code/shadcn/ui/packages/shadcn")
-c, --cwd <cwd> the working directory. defaults to the current directory.
-h, --help display help for command
```

View File

@@ -15,3 +15,4 @@ description: Every component recreated in Figma. With customizable props, typogr
## Free
- [shadcn/ui design system](https://www.figma.com/community/file/1203061493325953101) by [Pietro Schirano](https://twitter.com/skirano) - A design companion for shadcn/ui. Each component was painstakingly crafted to perfectly match the code implementation.
- [Obra shadcn/ui](https://www.figma.com/community/file/1514746685758799870/obra-shadcn-ui) by [Obra Studio](https://obra.studio/) - Carefully crafted kit designed in the philosophy of shadcn, tracks v4, MIT licensed

View File

@@ -41,7 +41,7 @@ npx shadcn@latest add calendar
<Step>Install the following dependencies:</Step>
```bash
npm install react-day-picker@8.10.1 date-fns
npm install react-day-picker date-fns
```
<Step>Add the `Button` component to your project.</Step>

View File

@@ -328,6 +328,20 @@ Add custom theme variables to the `theme` object.
}
```
## Add custom plugins
```json title="example-plugin.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "custom-plugin",
"type": "registry:component",
"css": {
"@plugin @tailwindcss/typography": {},
"@plugin foo": {}
}
}
```
## Add custom animations
Note: you need to define both `@keyframes` in css and `theme` in cssVars to use animations.

View File

@@ -260,11 +260,13 @@ Use to define CSS variables for your registry item.
### css
Use `css` to add new rules to the project's CSS file eg. `@layer base`, `@layer components`, `@utility`, `@keyframes`, etc.
Use `css` to add new rules to the project's CSS file eg. `@layer base`, `@layer components`, `@utility`, `@keyframes`, `@plugin`, etc.
```json title="registry-item.json" showLineNumbers
{
"css": {
"@plugin @tailwindcss/typography": {},
"@plugin foo": {},
"@layer base": {
"body": {
"font-size": "var(--text-base)",

View File

@@ -52,7 +52,7 @@ export const mdxComponents = {
.replace(/\?/g, "")
.toLowerCase()}
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
)}
{...props}
@@ -62,7 +62,7 @@ export const mdxComponents = {
h3: ({ className, ...props }: React.ComponentProps<"h3">) => (
<h3
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
)}
{...props}

View File

@@ -86,7 +86,7 @@
"recharts": "2.15.1",
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"shadcn": "2.6.1",
"shadcn": "2.8.0",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.1",

View File

@@ -12,7 +12,7 @@
"files": [
{
"path": "registry/new-york-v4/blocks/calendar-24.tsx",
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { Button } from \"@/registry/new-york-v4/ui/button\"\nimport { Calendar } from \"@/registry/new-york-v4/ui/calendar\"\nimport { Input } from \"@/registry/new-york-v4/ui/input\"\nimport { Label } from \"@/registry/new-york-v4/ui/label\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/registry/new-york-v4/ui/popover\"\n\nexport default function Calendar24() {\n const [open, setOpen] = React.useState(false)\n const [date, setDate] = React.useState<Date | undefined>(undefined)\n\n return (\n <div className=\"flex gap-4\">\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"date\" className=\"px-1\">\n Date\n </Label>\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n variant=\"outline\"\n id=\"date\"\n className=\"w-32 justify-between font-normal\"\n >\n {date ? date.toLocaleDateString() : \"Select date\"}\n <ChevronDownIcon />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto overflow-hidden p-0\" align=\"start\">\n <Calendar\n mode=\"single\"\n selected={date}\n captionLayout=\"dropdown\"\n onSelect={(date) => {\n setDate(date)\n setOpen(false)\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"time\" className=\"px-1\">\n Time\n </Label>\n <Input\n type=\"time\"\n id=\"time\"\n step=\"1\"\n defaultValue=\"10:30:00\"\n className=\"bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none\"\n />\n </div>\n </div>\n )\n}\n",
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { Button } from \"@/registry/new-york-v4/ui/button\"\nimport { Calendar } from \"@/registry/new-york-v4/ui/calendar\"\nimport { Input } from \"@/registry/new-york-v4/ui/input\"\nimport { Label } from \"@/registry/new-york-v4/ui/label\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/registry/new-york-v4/ui/popover\"\n\nexport default function Calendar24() {\n const [open, setOpen] = React.useState(false)\n const [date, setDate] = React.useState<Date | undefined>(undefined)\n\n return (\n <div className=\"flex gap-4\">\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"date-picker\" className=\"px-1\">\n Date\n </Label>\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n variant=\"outline\"\n id=\"date-picker\"\n className=\"w-32 justify-between font-normal\"\n >\n {date ? date.toLocaleDateString() : \"Select date\"}\n <ChevronDownIcon />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto overflow-hidden p-0\" align=\"start\">\n <Calendar\n mode=\"single\"\n selected={date}\n captionLayout=\"dropdown\"\n onSelect={(date) => {\n setDate(date)\n setOpen(false)\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"time-picker\" className=\"px-1\">\n Time\n </Label>\n <Input\n type=\"time\"\n id=\"time-picker\"\n step=\"1\"\n defaultValue=\"10:30:00\"\n className=\"bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none\"\n />\n </div>\n </div>\n )\n}\n",
"type": "registry:component"
}
],

View File

@@ -20,14 +20,14 @@ export default function Calendar24() {
return (
<div className="flex gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="date" className="px-1">
<Label htmlFor="date-picker" className="px-1">
Date
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date"
id="date-picker"
className="w-32 justify-between font-normal"
>
{date ? date.toLocaleDateString() : "Select date"}
@@ -48,12 +48,12 @@ export default function Calendar24() {
</Popover>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="time" className="px-1">
<Label htmlFor="time-picker" className="px-1">
Time
</Label>
<Input
type="time"
id="time"
id="time-picker"
step="1"
defaultValue="10:30:00"
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"

View File

@@ -248,7 +248,7 @@
font-size: var(--text-sm);
outline: none;
position: relative;
@apply md:-mx-4;
@apply md:-mx-1;
&:has([data-rehype-pretty-code-title]) [data-slot="copy-button"] {
top: calc(var(--spacing) * 1.5) !important;

6
apps/www/README.md Normal file
View File

@@ -0,0 +1,6 @@
# ui.shadcn.com
> [!WARNING]
> The site at ui.shadcn.com now points to `apps/v4`. All changes should be made in the `apps/v4` directory.
Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md).

View File

@@ -39,7 +39,7 @@ npx shadcn@latest add calendar
<Step>Install the following dependencies:</Step>
```bash
npm install react-day-picker@8.10.1 date-fns
npm install react-day-picker date-fns
```
<Step>Add the `Button` component to your project.</Step>

View File

@@ -10,3 +10,4 @@ description: Every component recreated in Figma. With customizable props, typogr
## Free
- [shadcn/ui design system](https://www.figma.com/community/file/1203061493325953101) by [Pietro Schirano](https://twitter.com/skirano) - A design companion for shadcn/ui. Each component was painstakingly crafted to perfectly match the code implementation.
- [Obra shadcn/ui](https://www.figma.com/community/file/1514746685758799870/obra-shadcn-ui) by [Obra Studio](https://https://obra.studio/) - Carefully crafted kit designed in the philosophy of shadcn, tracks v4, MIT licensed

View File

@@ -88,7 +88,7 @@
"react-resizable-panels": "^2.0.22",
"react-wrap-balancer": "^0.4.1",
"recharts": "2.12.7",
"shadcn": "2.6.1",
"shadcn": "2.8.0",
"sharp": "^0.32.6",
"sonner": "^1.2.3",
"swr": "2.2.6-beta.3",

View File

@@ -12,7 +12,7 @@
"files": [
{
"path": "registry/new-york-v4/blocks/calendar-24.tsx",
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { Button } from \"@/registry/new-york-v4/ui/button\"\nimport { Calendar } from \"@/registry/new-york-v4/ui/calendar\"\nimport { Input } from \"@/registry/new-york-v4/ui/input\"\nimport { Label } from \"@/registry/new-york-v4/ui/label\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/registry/new-york-v4/ui/popover\"\n\nexport default function Calendar24() {\n const [open, setOpen] = React.useState(false)\n const [date, setDate] = React.useState<Date | undefined>(undefined)\n\n return (\n <div className=\"flex gap-4\">\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"date\" className=\"px-1\">\n Date\n </Label>\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n variant=\"outline\"\n id=\"date\"\n className=\"w-32 justify-between font-normal\"\n >\n {date ? date.toLocaleDateString() : \"Select date\"}\n <ChevronDownIcon />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto overflow-hidden p-0\" align=\"start\">\n <Calendar\n mode=\"single\"\n selected={date}\n captionLayout=\"dropdown\"\n onSelect={(date) => {\n setDate(date)\n setOpen(false)\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"time\" className=\"px-1\">\n Time\n </Label>\n <Input\n type=\"time\"\n id=\"time\"\n step=\"1\"\n defaultValue=\"10:30:00\"\n className=\"bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none\"\n />\n </div>\n </div>\n )\n}\n",
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDownIcon } from \"lucide-react\"\n\nimport { Button } from \"@/registry/new-york-v4/ui/button\"\nimport { Calendar } from \"@/registry/new-york-v4/ui/calendar\"\nimport { Input } from \"@/registry/new-york-v4/ui/input\"\nimport { Label } from \"@/registry/new-york-v4/ui/label\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/registry/new-york-v4/ui/popover\"\n\nexport default function Calendar24() {\n const [open, setOpen] = React.useState(false)\n const [date, setDate] = React.useState<Date | undefined>(undefined)\n\n return (\n <div className=\"flex gap-4\">\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"date-picker\" className=\"px-1\">\n Date\n </Label>\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n variant=\"outline\"\n id=\"date-picker\"\n className=\"w-32 justify-between font-normal\"\n >\n {date ? date.toLocaleDateString() : \"Select date\"}\n <ChevronDownIcon />\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto overflow-hidden p-0\" align=\"start\">\n <Calendar\n mode=\"single\"\n selected={date}\n captionLayout=\"dropdown\"\n onSelect={(date) => {\n setDate(date)\n setOpen(false)\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n <div className=\"flex flex-col gap-3\">\n <Label htmlFor=\"time-picker\" className=\"px-1\">\n Time\n </Label>\n <Input\n type=\"time\"\n id=\"time-picker\"\n step=\"1\"\n defaultValue=\"10:30:00\"\n className=\"bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none\"\n />\n </div>\n </div>\n )\n}\n",
"type": "registry:component"
}
],

View File

@@ -79,7 +79,7 @@
{
"name": "calendar",
"dependencies": [
"react-day-picker@8.10.1",
"react-day-picker",
"date-fns"
],
"registryDependencies": [

View File

@@ -1,7 +1,7 @@
{
"name": "calendar",
"dependencies": [
"react-day-picker@8.10.1",
"react-day-picker",
"date-fns"
],
"registryDependencies": [

View File

@@ -1,7 +1,7 @@
{
"name": "calendar",
"dependencies": [
"react-day-picker@8.10.1",
"react-day-picker",
"date-fns"
],
"registryDependencies": [

View File

@@ -1,5 +1,47 @@
# @shadcn/ui
## 2.8.0
### Minor Changes
- [#7720](https://github.com/shadcn-ui/ui/pull/7720) [`d544a7f7a519cd5b171d9ee7cb2fd1a226659ece`](https://github.com/shadcn-ui/ui/commit/d544a7f7a519cd5b171d9ee7cb2fd1a226659ece) Thanks [@shadcn](https://github.com/shadcn)! - refactor registry dependencies resolution
- [#7717](https://github.com/shadcn-ui/ui/pull/7717) [`48fe0d709fd2b244314f95f56e7afb38b117ed8a`](https://github.com/shadcn-ui/ui/commit/48fe0d709fd2b244314f95f56e7afb38b117ed8a) Thanks [@shadcn](https://github.com/shadcn)! - add support for local registry item
- [#6330](https://github.com/shadcn-ui/ui/pull/6330) [`ed244ea0b5abf7db50ac5fcf26e2993133fe94f7`](https://github.com/shadcn-ui/ui/commit/ed244ea0b5abf7db50ac5fcf26e2993133fe94f7) Thanks [@KitsuneDev](https://github.com/KitsuneDev)! - add support for vinxi based framework
## 2.7.0
### Minor Changes
- [#7540](https://github.com/shadcn-ui/ui/pull/7540) [`cb19ab84646fc017c15fadc81fc47b695560a04c`](https://github.com/shadcn-ui/ui/commit/cb19ab84646fc017c15fadc81fc47b695560a04c) Thanks [@mrzachnugent](https://github.com/mrzachnugent)! - add support for expo
- [#7640](https://github.com/shadcn-ui/ui/pull/7640) [`7c3d34cdc91681815f8897709917ec9fbcd69245`](https://github.com/shadcn-ui/ui/commit/7c3d34cdc91681815f8897709917ec9fbcd69245) Thanks [@shadcn](https://github.com/shadcn)! - add support for @plugin in css
### Patch Changes
- [#7609](https://github.com/shadcn-ui/ui/pull/7609) [`5b8ee41511fb5ff468d9218f97b8545e145d773c`](https://github.com/shadcn-ui/ui/commit/5b8ee41511fb5ff468d9218f97b8545e145d773c) Thanks [@xabierlameiro](https://github.com/xabierlameiro)! - fix typo in function name unnsetSpreadElements
## 2.6.4
### Patch Changes
- [#7601](https://github.com/shadcn-ui/ui/pull/7601) [`c86c27a2ffb8d186770afa42bfb62ab46e3db975`](https://github.com/shadcn-ui/ui/commit/c86c27a2ffb8d186770afa42bfb62ab46e3db975) Thanks [@schiller-manuel](https://github.com/schiller-manuel)! - fix tanstack start detection
## 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
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "shadcn",
"version": "2.6.1",
"version": "2.8.0",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"

View File

@@ -1,8 +1,9 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightAdd } from "@/src/preflights/preflight-add"
import { getRegistryIndex, getRegistryItem, isUrl } from "@/src/registry/api"
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
import { registryItemTypeSchema } from "@/src/registry/schema"
import { isLocalFile, isUrl } from "@/src/registry/utils"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import * as ERRORS from "@/src/utils/errors"
@@ -46,10 +47,7 @@ export const addOptionsSchema = z.object({
export const add = new Command()
.name("add")
.description("add a component to your project")
.argument(
"[components...]",
"the components to add or a url to the component."
)
.argument("[components...]", "names, url or local path to component")
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-o, --overwrite", "overwrite existing files.", false)
.option(
@@ -81,7 +79,10 @@ export const add = new Command()
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
if (components.length > 0 && isUrl(components[0])) {
if (
components.length > 0 &&
(isUrl(components[0]) || isLocalFile(components[0]))
) {
const item = await getRegistryItem(components[0], "")
itemType = item?.type
}

View File

@@ -6,8 +6,8 @@ import {
getRegistryBaseColors,
getRegistryItem,
getRegistryStyles,
isUrl,
} from "@/src/registry/api"
import { isLocalFile, isUrl } from "@/src/registry/utils"
import { addComponents } from "@/src/utils/add-components"
import { TEMPLATES, createProject } from "@/src/utils/create-project"
import * as ERRORS from "@/src/utils/errors"
@@ -82,10 +82,7 @@ export const initOptionsSchema = z.object({
export const init = new Command()
.name("init")
.description("initialize your project and install dependencies")
.argument(
"[components...]",
"the components to add or a url to the component."
)
.argument("[components...]", "names, url or local path to component")
.option(
"-t, --template <template>",
"the template to use. (next, next-monorepo)"
@@ -128,7 +125,10 @@ export const init = new Command()
// We need to check if we're initializing with a new style.
// We fetch the payload of the first item.
// This is okay since the request is cached and deduped.
if (components.length > 0 && isUrl(components[0])) {
if (
components.length > 0 &&
(isUrl(components[0]) || isLocalFile(components[0]))
) {
const item = await getRegistryItem(components[0], "")
// Skip base color if style.

View File

@@ -1,5 +1,6 @@
import path from "path"
import { migrateIcons } from "@/src/migrations/migrate-icons"
import { migrateRadix } from "@/src/migrations/migrate-radix"
import { preFlightMigrate } from "@/src/preflights/preflight-migrate"
import * as ERRORS from "@/src/utils/errors"
import { handleError } from "@/src/utils/handle-error"
@@ -12,11 +13,16 @@ export const migrations = [
name: "icons",
description: "migrate your ui components to a different icon library.",
},
{
name: "radix",
description: "migrate to radix-ui.",
},
] as const
export const migrateOptionsSchema = z.object({
cwd: z.string(),
list: z.boolean(),
yes: z.boolean(),
migration: z
.string()
.refine(
@@ -40,12 +46,14 @@ export const migrate = new Command()
process.cwd()
)
.option("-l, --list", "list all migrations.", false)
.option("-y, --yes", "skip confirmation prompt.", false)
.action(async (migration, opts) => {
try {
const options = migrateOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
migration,
list: opts.list,
yes: opts.yes,
})
if (options.list || !options.migration) {
@@ -82,6 +90,10 @@ export const migrate = new Command()
if (options.migration === "icons") {
await migrateIcons(config)
}
if (options.migration === "radix") {
await migrateRadix(config, { yes: options.yes })
}
} catch (error) {
logger.break()
handleError(error)

File diff suppressed because it is too large Load Diff

View 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,
}
}

View File

@@ -1,12 +1,54 @@
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import { HttpResponse, http } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest"
import { clearRegistryCache, fetchRegistry } from "./api"
import {
clearRegistryCache,
fetchRegistry,
getRegistryItem,
registryResolveItemsTree,
} from "./api"
// Mock the handleError function to prevent process.exit in tests
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn(),
}))
// Mock the logger to prevent console output in tests
vi.mock("@/src/utils/logger", () => ({
logger: {
error: vi.fn(),
break: vi.fn(),
log: vi.fn(),
},
}))
const REGISTRY_URL = "https://ui.shadcn.com/r"
const server = setupServer(
http.get(`${REGISTRY_URL}/index.json`, () => {
return HttpResponse.json([
{
name: "button",
type: "registry:ui",
},
{
name: "card",
type: "registry:ui",
},
])
}),
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, () => {
return HttpResponse.json({
name: "button",
@@ -112,3 +154,277 @@ describe("fetchRegistry", () => {
expect(result[1]).toMatchObject({ name: "card" })
})
})
describe("getRegistryItem with local files", () => {
it("should read and parse a valid local JSON file", async () => {
// Create a temporary file
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "test-component.json")
const componentData = {
name: "test-component",
type: "registry:ui",
dependencies: ["@radix-ui/react-dialog"],
files: [
{
path: "ui/test-component.tsx",
content: "// test component content",
type: "registry:ui",
},
],
}
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toMatchObject({
name: "test-component",
type: "registry:ui",
dependencies: ["@radix-ui/react-dialog"],
files: [
{
path: "ui/test-component.tsx",
content: "// test component content",
type: "registry:ui",
},
],
})
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should handle relative paths", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "relative-component.json")
const componentData = {
name: "relative-component",
type: "registry:ui",
files: [],
}
await fs.writeFile(tempFile, JSON.stringify(componentData))
try {
// Change to temp directory to test relative path
const originalCwd = process.cwd()
process.chdir(tempDir)
const result = await getRegistryItem(
"./relative-component.json",
"unused-style"
)
expect(result).toMatchObject({
name: "relative-component",
type: "registry:ui",
})
process.chdir(originalCwd)
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should handle tilde (~) home directory paths", async () => {
const os = await import("os")
const homeDir = os.homedir()
const tempFile = path.join(homeDir, "shadcn-test-tilde.json")
const componentData = {
name: "tilde-component",
type: "registry:ui",
files: [],
}
await fs.writeFile(tempFile, JSON.stringify(componentData))
try {
// Test with tilde path
const tildeePath = "~/shadcn-test-tilde.json"
const result = await getRegistryItem(tildeePath, "unused-style")
expect(result).toMatchObject({
name: "tilde-component",
type: "registry:ui",
})
} finally {
// Clean up
await fs.unlink(tempFile)
}
})
it("should return null for non-existent files", async () => {
const result = await getRegistryItem(
"/non/existent/file.json",
"unused-style"
)
expect(result).toBe(null)
})
it("should return null for invalid JSON", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid.json")
await fs.writeFile(tempFile, "{ invalid json }")
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toBe(null)
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should return null for JSON that doesn't match registry schema", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid-schema.json")
const invalidData = {
notAValidRegistryItem: true,
missing: "required fields",
}
await fs.writeFile(tempFile, JSON.stringify(invalidData))
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toBe(null)
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should still handle URLs and component names", async () => {
// Test that existing functionality still works
const result = await getRegistryItem("button", "new-york")
expect(result).toMatchObject({
name: "button",
type: "registry:ui",
})
})
it("should handle local files with URL dependencies", async () => {
// Mock a URL endpoint for dependency
const dependencyUrl = "https://example.com/dependency.json"
server.use(
http.get(dependencyUrl, () => {
return HttpResponse.json({
name: "url-dependency",
type: "registry:ui",
files: [
{
path: "ui/url-dependency.tsx",
content: "// url dependency content",
type: "registry:ui",
},
],
})
})
)
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "component-with-url-deps.json")
const componentData = {
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl, "button"], // Mix of URL and registry name
files: [
{
path: "ui/component-with-url-deps.tsx",
content: "// component with url deps content",
type: "registry:ui",
},
],
}
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toMatchObject({
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl, "button"],
})
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
})
describe("registryResolveItemsTree with URL dependencies", () => {
it("should resolve URL dependencies from local files", async () => {
// Mock a URL endpoint for dependency
const dependencyUrl = "https://example.com/dependency.json"
server.use(
http.get(dependencyUrl, () => {
return HttpResponse.json({
name: "url-dependency",
type: "registry:ui",
files: [
{
path: "ui/url-dependency.tsx",
content: "// url dependency content",
type: "registry:ui",
},
],
})
})
)
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "component-with-url-deps.json")
const componentData = {
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl], // URL dependency
files: [
{
path: "ui/component-with-url-deps.tsx",
content: "// component with url deps content",
type: "registry:ui",
},
],
}
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
resolvedPaths: { cwd: process.cwd() },
} as any
const result = await registryResolveItemsTree([tempFile], mockConfig)
expect(result).toBeDefined()
expect(result?.files).toBeDefined()
// Should contain files from both the main component and its URL dependency
const filePaths = result?.files?.map((f: any) => f.path) ?? []
expect(filePaths).toContain("ui/component-with-url-deps.tsx")
expect(filePaths).toContain("ui/url-dependency.tsx")
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
})

View File

@@ -1,4 +1,7 @@
import { promises as fs } from "fs"
import { homedir } from "os"
import path from "path"
import { isLocalFile } from "@/src/registry/utils"
import { Config, getTargetStyleFromConfig } from "@/src/utils/get-config"
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
@@ -85,6 +88,12 @@ export async function getRegistryIcons() {
export async function getRegistryItem(name: string, style: string) {
try {
// Handle local file paths
if (isLocalFile(name)) {
return await getLocalRegistryItem(name)
}
// Handle URLs and component names
const [result] = await fetchRegistry([
isUrl(name) ? name : `styles/${style}/${name}.json`,
])
@@ -97,6 +106,26 @@ export async function getRegistryItem(name: string, style: string) {
}
}
async function getLocalRegistryItem(filePath: string) {
try {
// Handle tilde expansion for home directory
let expandedPath = filePath
if (filePath.startsWith("~/")) {
expandedPath = path.join(homedir(), filePath.slice(2))
}
const resolvedPath = path.resolve(expandedPath)
const content = await fs.readFile(resolvedPath, "utf8")
const parsed = JSON.parse(content)
return registryItemSchema.parse(parsed)
} catch (error) {
logger.error(`Failed to read local registry file: ${filePath}`)
handleError(error)
return null
}
}
export async function getRegistryBaseColors() {
return BASE_COLORS
}
@@ -263,26 +292,144 @@ export function clearRegistryCache() {
registryCache.clear()
}
async function resolveDependenciesRecursively(
dependencies: string[],
config?: Config,
visited: Set<string> = new Set()
): Promise<{
items: z.infer<typeof registryItemSchema>[]
registryNames: string[]
}> {
const items: z.infer<typeof registryItemSchema>[] = []
const registryNames: string[] = []
for (const dep of dependencies) {
// Avoid infinite recursion.
if (visited.has(dep)) {
continue
}
visited.add(dep)
if (isUrl(dep) || isLocalFile(dep)) {
const item = await getRegistryItem(dep, "")
if (item) {
items.push(item)
if (item.registryDependencies) {
const nested = await resolveDependenciesRecursively(
item.registryDependencies,
config,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
}
} else {
// Registry name - add it to the list
registryNames.push(dep)
// If we have config, we can also fetch the item to get its dependencies
if (config) {
const style = config.resolvedPaths?.cwd
? await getTargetStyleFromConfig(
config.resolvedPaths.cwd,
config.style
)
: config.style
try {
const item = await getRegistryItem(dep, style)
if (item && item.registryDependencies) {
const nested = await resolveDependenciesRecursively(
item.registryDependencies,
config,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
} catch (error) {
// If we can't fetch the registry item, that's okay - we'll still include the name
}
}
}
}
return { items, registryNames }
}
export async function registryResolveItemsTree(
names: z.infer<typeof registryItemSchema>["name"][],
config: Config
) {
try {
const index = await getRegistryIndex()
if (!index) {
return null
// Separate local files, URLs, and registry names.
const localFiles = names.filter((name) => isLocalFile(name))
const urls = names.filter((name) => isUrl(name))
const registryNames = names.filter(
(name) => !isLocalFile(name) && !isUrl(name)
)
const payload: z.infer<typeof registryItemSchema>[] = []
// Handle local files and URLs directly, collecting their dependencies.
const allDependencies: string[] = []
for (const localFile of localFiles) {
const item = await getRegistryItem(localFile, "")
if (item) {
payload.push(item)
if (item.registryDependencies) {
allDependencies.push(...item.registryDependencies)
}
}
}
// If we're resolving the index, we want it to go first.
if (names.includes("index")) {
names.unshift("index")
for (const url of urls) {
const item = await getRegistryItem(url, "")
if (item) {
payload.push(item)
if (item.registryDependencies) {
allDependencies.push(...item.registryDependencies)
}
}
}
let registryItems = await resolveRegistryItems(names, config)
let result = await fetchRegistry(registryItems)
const payload = z.array(registryItemSchema).parse(result)
// Recursively resolve all dependencies.
const { items: dependencyItems, registryNames: dependencyRegistryNames } =
await resolveDependenciesRecursively(allDependencies, config)
if (!payload) {
payload.push(...dependencyItems)
// Handle registry names using existing resolveRegistryItems logic.
const allRegistryNames = [...registryNames, ...dependencyRegistryNames]
if (allRegistryNames.length > 0) {
const index = await getRegistryIndex()
if (!index) {
// If we only have local files or URLs, that's fine.
if (payload.length === 0) {
return null
}
} else {
// Remove duplicates.
const uniqueRegistryNames = Array.from(new Set(allRegistryNames))
// If we're resolving the index, we want it to go first.
if (uniqueRegistryNames.includes("index")) {
uniqueRegistryNames.unshift("index")
}
let registryItems = await resolveRegistryItems(
uniqueRegistryNames,
config
)
let result = await fetchRegistry(registryItems)
const registryPayload = z.array(registryItemSchema).parse(result)
payload.push(...registryPayload)
}
}
if (!payload.length) {
return null
}
@@ -290,7 +437,7 @@ export async function registryResolveItemsTree(
// the theme item if a base color is provided.
// We do this for index only.
// Other components will ship with their theme tokens.
if (names.includes("index")) {
if (allRegistryNames.includes("index")) {
if (config.tailwind.baseColor) {
const theme = await registryGetTheme(config.tailwind.baseColor, config)
if (theme) {
@@ -352,44 +499,17 @@ async function resolveRegistryDependencies(
url: string,
config: Config
): Promise<string[]> {
const visited = new Set<string>()
const payload: string[] = []
const { registryNames } = await resolveDependenciesRecursively([url], config)
const style = config.resolvedPaths?.cwd
? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style)
: config.style
async function resolveDependencies(itemUrl: string) {
const url = getRegistryUrl(
isUrl(itemUrl) ? itemUrl : `styles/${style}/${itemUrl}.json`
)
const urls = registryNames.map((name) =>
getRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`)
)
if (visited.has(url)) {
return
}
visited.add(url)
try {
const [result] = await fetchRegistry([url])
const item = registryItemSchema.parse(result)
payload.push(url)
if (item.registryDependencies) {
for (const dependency of item.registryDependencies) {
await resolveDependencies(dependency)
}
}
} catch (error) {
console.error(
`Error fetching or parsing registry item at ${itemUrl}:`,
error
)
}
}
await resolveDependencies(url)
return Array.from(new Set(payload))
return Array.from(new Set(urls))
}
export async function registryGetTheme(name: string, config: Config) {
@@ -495,7 +615,13 @@ export function isUrl(path: string) {
// TODO: We're double-fetching here. Use a cache.
export async function resolveRegistryItems(names: string[], config: Config) {
let registryDependencies: string[] = []
for (const name of names) {
// Filter out local files and URLs - these should be handled directly by getRegistryItem
const registryNames = names.filter(
(name) => !isLocalFile(name) && !isUrl(name)
)
for (const name of registryNames) {
const itemRegistryDependencies = await resolveRegistryDependencies(
name,
config

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { getDependencyFromModuleSpecifier } from "./utils"
import { getDependencyFromModuleSpecifier, isLocalFile, isUrl } from "./utils"
describe("getDependencyFromModuleSpecifier", () => {
it("should return the first part of a non-scoped package with path", () => {
@@ -74,3 +74,59 @@ describe("getDependencyFromModuleSpecifier", () => {
)
})
})
describe("isUrl", () => {
it("should return true for valid URLs", () => {
expect(isUrl("https://example.com")).toBe(true)
expect(isUrl("http://example.com")).toBe(true)
expect(isUrl("https://example.com/path")).toBe(true)
expect(isUrl("https://subdomain.example.com")).toBe(true)
expect(isUrl("https://ui.shadcn.com/r/styles/new-york/button.json")).toBe(
true
)
})
it("should return false for non-URLs", () => {
expect(isUrl("./local-file.json")).toBe(false)
expect(isUrl("../relative/path.json")).toBe(false)
expect(isUrl("/absolute/path.json")).toBe(false)
expect(isUrl("component-name")).toBe(false)
expect(isUrl("")).toBe(false)
expect(isUrl("just-text")).toBe(false)
})
})
describe("isLocalFile", () => {
it("should return true for local JSON files", () => {
expect(isLocalFile("./component.json")).toBe(true)
expect(isLocalFile("../shared/button.json")).toBe(true)
expect(isLocalFile("/absolute/path/card.json")).toBe(true)
expect(isLocalFile("local-component.json")).toBe(true)
expect(isLocalFile("nested/directory/dialog.json")).toBe(true)
expect(isLocalFile("~/Desktop/component.json")).toBe(true)
expect(isLocalFile("~/Documents/shared/button.json")).toBe(true)
})
it("should return false for URLs ending with .json", () => {
expect(isLocalFile("https://example.com/component.json")).toBe(false)
expect(isLocalFile("http://registry.com/button.json")).toBe(false)
expect(
isLocalFile("https://ui.shadcn.com/r/styles/new-york/button.json")
).toBe(false)
})
it("should return false for non-JSON files", () => {
expect(isLocalFile("./component.tsx")).toBe(false)
expect(isLocalFile("../shared/button.ts")).toBe(false)
expect(isLocalFile("/absolute/path/card.js")).toBe(false)
expect(isLocalFile("local-component.css")).toBe(false)
expect(isLocalFile("component-name")).toBe(false)
expect(isLocalFile("")).toBe(false)
})
it("should return false for directory paths", () => {
expect(isLocalFile("./components/")).toBe(false)
expect(isLocalFile("../shared")).toBe(false)
expect(isLocalFile("/absolute/path")).toBe(false)
})
})

View File

@@ -242,3 +242,17 @@ function determineFileType(
return "registry:component"
}
// Additional utility functions for local file support
export function isUrl(path: string) {
try {
new URL(path)
return true
} catch (error) {
return false
}
}
export function isLocalFile(path: string) {
return path.endsWith(".json") && !isUrl(path)
}

View File

@@ -1,8 +1,17 @@
import { fetchRegistry } from "@/src/registry/api"
import { spinner } from "@/src/utils/spinner"
import { execa } from "execa"
import fs from "fs-extra"
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"
@@ -14,16 +23,85 @@ vi.mock("@/src/registry/api")
vi.mock("@/src/utils/get-package-manager", () => ({
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", () => {
let mockExit: MockInstance
beforeEach(() => {
vi.clearAllMocks()
// Reset all fs mocks
vi.mocked(fs.access).mockResolvedValue(undefined)
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(() => {
vi.resetAllMocks()
mockExit?.mockRestore()
delete (global as any).fetch
})
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 () => {
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" })
const mockExit = vi
mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
@@ -103,7 +184,7 @@ describe("createProject", () => {
vi.mocked(fs.access).mockRejectedValue(new Error("Permission denied"))
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
const mockExit = vi
mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)

View File

@@ -72,6 +72,14 @@ export const FRAMEWORKS = {
tailwind: "https://tailwindcss.com/docs/guides/gatsby",
},
},
expo: {
name: "expo",
label: "Expo",
links: {
installation: "https://ui.shadcn.com/docs/installation/expo",
tailwind: "https://www.nativewind.dev/docs/getting-started/installation",
},
},
manual: {
name: "manual",
label: "Manual",

View File

@@ -121,11 +121,10 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
// TanStack Start.
if (
configFiles.find((file) => file.startsWith("app.config."))?.length &&
[
...Object.keys(packageJson?.dependencies ?? {}),
...Object.keys(packageJson?.devDependencies ?? {}),
].find((dep) => dep.startsWith("@tanstack/start"))
].find((dep) => dep.startsWith("@tanstack/react-start"))
) {
type.framework = FRAMEWORKS["tanstack-start"]
return type
@@ -147,6 +146,26 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
return type
}
// Vinxi-based (such as @tanstack/start and @solidjs/solid-start)
// They are vite-based, and the same configurations used for Vite should work flawlessly
const appConfig = configFiles.find((file) => file.startsWith("app.config"))
if (appConfig?.length) {
const appConfigContents = await fs.readFile(
path.resolve(cwd, appConfig),
"utf8"
)
if (appConfigContents.includes("defineConfig")) {
type.framework = FRAMEWORKS["vite"]
return type
}
}
// Expo.
if (packageJson?.dependencies?.expo) {
type.framework = FRAMEWORKS["expo"]
return type
}
return type
}

View File

@@ -79,8 +79,70 @@ function updateCssPlugin(css: z.infer<typeof registryItemCssSchema>) {
const [, name, params] = atRuleMatch
// Special handling for plugins - place them after imports
if (name === "plugin") {
// Find existing plugin with same params
const existingPlugin = root.nodes?.find(
(node): node is AtRule =>
node.type === "atrule" &&
node.name === "plugin" &&
node.params === params
)
if (!existingPlugin) {
const pluginRule = postcss.atRule({
name: "plugin",
params,
raws: { semicolon: true, before: "\n" },
})
// Find the last import or plugin node to insert after
const importNodes = root.nodes?.filter(
(node): node is AtRule =>
node.type === "atrule" && node.name === "import"
)
const pluginNodes = root.nodes?.filter(
(node): node is AtRule =>
node.type === "atrule" && node.name === "plugin"
)
if (pluginNodes && pluginNodes.length > 0) {
// Insert after the last existing plugin
const lastPlugin = pluginNodes[pluginNodes.length - 1]
root.insertAfter(lastPlugin, pluginRule)
} else if (importNodes && importNodes.length > 0) {
// Insert after the last import if no plugins exist
const lastImport = importNodes[importNodes.length - 1]
root.insertAfter(lastImport, pluginRule)
// Add a break comment before the first plugin to create spacing
root.insertBefore(
pluginRule,
postcss.comment({ text: "---break---" })
)
// Add a break comment after the plugin for spacing from other content
root.insertAfter(
pluginRule,
postcss.comment({ text: "---break---" })
)
} else {
// If no imports or plugins, insert at the beginning
root.prepend(pluginRule)
// Add a break comment before the first plugin for spacing
root.insertBefore(
pluginRule,
postcss.comment({ text: "---break---" })
)
// Add a break comment after the plugin for spacing from other content
root.insertAfter(
pluginRule,
postcss.comment({ text: "---break---" })
)
}
}
}
// Special handling for keyframes - place them under @theme inline
if (name === "keyframes") {
else if (name === "keyframes") {
let themeInline = root.nodes?.find(
(node): node is AtRule =>
node.type === "atrule" &&

View File

@@ -30,7 +30,7 @@ export async function updateDependencies(
const dependenciesSpinner = spinner(`Installing dependencies.`, {
silent: options.silent,
})?.start()
const packageManager = await getPackageManager(config.resolvedPaths.cwd)
const packageManager = await getUpdateDependenciesPackageManager(config)
// Offer to use --force or --legacy-peer-deps if using React 19 with npm.
let flag = ""
@@ -62,38 +62,13 @@ export async function updateDependencies(
dependenciesSpinner?.start()
if (dependencies?.length) {
await execa(
packageManager,
[
packageManager === "npm" ? "install" : "add",
...(packageManager === "npm" && flag ? [`--${flag}`] : []),
...(packageManager === "deno"
? dependencies.map((dep) => `npm:${dep}`)
: dependencies),
],
{
cwd: config.resolvedPaths.cwd,
}
)
}
if (devDependencies?.length) {
await execa(
packageManager,
[
packageManager === "npm" ? "install" : "add",
...(packageManager === "npm" && flag ? [`--${flag}`] : []),
"-D",
...(packageManager === "deno"
? devDependencies.map((dep) => `npm:${dep}`)
: devDependencies),
],
{
cwd: config.resolvedPaths.cwd,
}
)
}
await installWithPackageManager(
packageManager,
dependencies,
devDependencies,
config.resolvedPaths.cwd,
flag
)
dependenciesSpinner?.succeed()
}
@@ -113,3 +88,107 @@ function shouldPromptForNpmFlag(config: Config) {
return hasReact19 && hasReactDayPicker8
}
async function getUpdateDependenciesPackageManager(config: Config) {
const expoVersion = getPackageInfo(config.resolvedPaths.cwd, false)
?.dependencies?.expo
if (expoVersion) {
// Ensures package versions match the React Native version.
// https://docs.expo.dev/more/expo-cli/#install
return "expo"
}
return getPackageManager(config.resolvedPaths.cwd)
}
async function installWithPackageManager(
packageManager: Awaited<
ReturnType<typeof getUpdateDependenciesPackageManager>
>,
dependencies: string[],
devDependencies: string[],
cwd: string,
flag?: string
) {
if (packageManager === "npm") {
return installWithNpm(dependencies, devDependencies, cwd, flag)
}
if (packageManager === "deno") {
return installWithDeno(dependencies, devDependencies, cwd)
}
if (packageManager === "expo") {
return installWithExpo(dependencies, devDependencies, cwd)
}
if (dependencies?.length) {
await execa(packageManager, ["add", ...dependencies], {
cwd,
})
}
if (devDependencies?.length) {
await execa(packageManager, ["add", "-D", ...devDependencies], { cwd })
}
}
async function installWithNpm(
dependencies: string[],
devDependencies: string[],
cwd: string,
flag?: string
) {
if (dependencies.length) {
await execa(
"npm",
["install", ...(flag ? [`--${flag}`] : []), ...dependencies],
{ cwd }
)
}
if (devDependencies.length) {
await execa(
"npm",
["install", ...(flag ? [`--${flag}`] : []), "-D", ...devDependencies],
{ cwd }
)
}
}
async function installWithDeno(
dependencies: string[],
devDependencies: string[],
cwd: string
) {
if (dependencies?.length) {
await execa("deno", ["add", ...dependencies.map((dep) => `npm:${dep}`)], {
cwd,
})
}
if (devDependencies?.length) {
await execa(
"deno",
["add", "-D", ...devDependencies.map((dep) => `npm:${dep}`)],
{ cwd }
)
}
}
async function installWithExpo(
dependencies: string[],
devDependencies: string[],
cwd: string
) {
if (dependencies.length) {
await execa("npx", ["expo", "install", ...dependencies], { cwd })
}
if (devDependencies.length) {
await execa("npx", ["expo", "install", "-- -D", ...devDependencies], {
cwd,
})
}
}

View File

@@ -379,7 +379,7 @@ export function unnestSpreadProperties(obj: ObjectLiteralExpression) {
initializer &&
initializer.isKind(SyntaxKind.ArrayLiteralExpression)
) {
unnsetSpreadElements(
unsetSpreadElements(
initializer.asKindOrThrow(SyntaxKind.ArrayLiteralExpression)
)
}
@@ -387,7 +387,7 @@ export function unnestSpreadProperties(obj: ObjectLiteralExpression) {
}
}
export function unnsetSpreadElements(arr: ArrayLiteralExpression) {
export function unsetSpreadElements(arr: ArrayLiteralExpression) {
const elements = arr.getElements()
for (let j = 0; j < elements.length; j++) {
const element = elements[j]
@@ -398,7 +398,7 @@ export function unnsetSpreadElements(arr: ArrayLiteralExpression) {
)
} else if (element.isKind(SyntaxKind.ArrayLiteralExpression)) {
// Recursive check on nested arrays
unnsetSpreadElements(
unsetSpreadElements(
element.asKindOrThrow(SyntaxKind.ArrayLiteralExpression)
)
} else if (element.isKind(SyntaxKind.StringLiteral)) {

View File

@@ -328,4 +328,290 @@ describe("transformCss", () => {
}"
`)
})
test("should add plugin directive", async () => {
const input = `@import "tailwindcss";`
const result = await transformCss(input, {
"@plugin foo": {},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@plugin foo;"
`)
})
test("should group plugins together after imports", async () => {
const input = `@import "tailwindcss";
@layer base {
body {
font-family: sans-serif;
}
}
@utility content-auto {
content-visibility: auto;
}`
const result = await transformCss(input, {
"@plugin foo": {},
"@plugin bar": {},
"@layer components": {
".card": {
padding: "1rem",
},
},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@plugin foo;
@plugin bar;
@layer base {
body {
font-family: sans-serif;
}
}
@utility content-auto {
content-visibility: auto;
}
@layer components {
.card {
padding: 1rem;
}
}"
`)
})
test("should not add duplicate plugins", async () => {
const input = `@import "tailwindcss";
@plugin foo;
@layer base {
body {
font-family: sans-serif;
}
}`
const result = await transformCss(input, {
"@plugin foo": {},
"@plugin bar": {},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@plugin foo;
@plugin bar;
@layer base {
body {
font-family: sans-serif;
}
}"
`)
})
test("should add plugin when no imports exist", async () => {
const input = `@layer base {
body {
font-family: sans-serif;
}
}`
const result = await transformCss(input, {
"@plugin foo": {},
})
expect(result).toMatchInlineSnapshot(`
"
@plugin foo;
@layer base {
body {
font-family: sans-serif;
}
}"
`)
})
test("should handle plugins with quoted parameters", async () => {
const input = `@import "tailwindcss";`
const result = await transformCss(input, {
"@plugin @tailwindcss/typography": {},
"@plugin ./custom-plugin.js": {},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@plugin @tailwindcss/typography;
@plugin ./custom-plugin.js;"
`)
})
test("should handle plugins with complex parameters", async () => {
const input = `@import "tailwindcss";`
const result = await transformCss(input, {
"@plugin tailwindcss/plugin": {},
"@plugin @headlessui/tailwindcss": {},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@plugin tailwindcss/plugin;
@plugin @headlessui/tailwindcss;"
`)
})
test("should handle multiple imports with plugins", async () => {
const input = `@import "tailwindcss";
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");`
const result = await transformCss(input, {
"@plugin foo": {},
"@plugin bar": {},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@plugin foo;
@plugin bar;"
`)
})
test("should add plugins to empty file", async () => {
const input = ``
const result = await transformCss(input, {
"@plugin foo": {},
})
expect(result).toMatchInlineSnapshot(`
"
@plugin foo"
`)
})
test("should maintain plugin order with existing plugins", async () => {
const input = `@import "tailwindcss";
@plugin existing-plugin;
@plugin another-existing;`
const result = await transformCss(input, {
"@plugin new-plugin": {},
"@plugin final-plugin": {},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@plugin existing-plugin;
@plugin another-existing;
@plugin new-plugin;
@plugin final-plugin;"
`)
})
test("should handle comprehensive CSS with plugins", async () => {
const input = `@import "tailwindcss";
@import url("fonts.css");
@layer base {
* {
box-sizing: border-box;
}
}
@utility content-auto {
content-visibility: auto;
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}`
const result = await transformCss(input, {
"@plugin @tailwindcss/typography": {},
"@plugin ./custom": {},
"@layer components": {
".btn": {
padding: "0.5rem 1rem",
"&:hover": {
"background-color": "blue",
},
},
},
"@utility animate-fast": {
"animation-duration": "0.1s",
},
"@keyframes spin-fast": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@import url("fonts.css");
@plugin @tailwindcss/typography;
@plugin ./custom;
@layer base {
* {
box-sizing: border-box;
}
}
@utility content-auto {
content-visibility: auto;
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@layer components {
.btn {
padding: 0.5rem 1rem;
}
.btn:hover {
background-color: blue;
}
}
@utility animate-fast {
animation-duration: 0.1s;
}
@theme inline {
@keyframes spin-fast {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}"
`)
})
})

View File

@@ -5,7 +5,7 @@ import {
buildTailwindThemeColorsFromCssVars, nestSpreadElements,
nestSpreadProperties,
transformTailwindConfig,
unnestSpreadProperties, unnsetSpreadElements,
unnestSpreadProperties, unsetSpreadElements,
} from "../../../src/utils/updaters/update-tailwind-config"
const SHARED_CONFIG = {
@@ -1186,7 +1186,7 @@ describe("unnestSpreadElements", () => {
)
if (!configObject) throw new Error("Config object not found")
unnsetSpreadElements(configObject)
unsetSpreadElements(configObject)
const result = configObject.getText()
expect(result.replace(/\s+/g, "")).toBe(expected.replace(/\s+/g, ""))

70
pnpm-lock.yaml generated
View File

@@ -322,7 +322,7 @@ importers:
specifier: ^6.0.1
version: 6.0.1
shadcn:
specifier: 2.6.1
specifier: 2.8.0
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1
@@ -602,7 +602,7 @@ importers:
specifier: 2.12.7
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
shadcn:
specifier: 2.6.1
specifier: 2.8.0
version: link:../../packages/shadcn
sharp:
specifier: ^0.32.6
@@ -696,16 +696,6 @@ importers:
specifier: ^4.1.2
version: 4.1.2
packages/cli:
dependencies:
chalk:
specifier: ^5.4.1
version: 5.4.1
devDependencies:
tsup:
specifier: ^6.6.3
version: 6.7.0(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)
packages/shadcn:
dependencies:
'@antfu/ni':
@@ -4779,10 +4769,6 @@ packages:
resolution: {integrity: sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chalk@5.4.1:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
@@ -14349,8 +14335,6 @@ snapshots:
chalk@5.2.0: {}
chalk@5.4.1: {}
change-case@5.4.4: {}
character-entities-html4@2.1.0: {}
@@ -18447,14 +18431,6 @@ snapshots:
postcss: 8.5.1
ts-node: 10.9.2(@types/node@22.13.0)(typescript@4.9.5)
postcss-load-config@3.1.4(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)):
dependencies:
lilconfig: 2.1.0
yaml: 1.10.2
optionalDependencies:
postcss: 8.5.1
ts-node: 10.9.2(@types/node@22.13.0)(typescript@5.7.3)
postcss-load-config@4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@17.0.45)(typescript@5.7.3)):
dependencies:
lilconfig: 3.1.3
@@ -20100,25 +20076,6 @@ snapshots:
yn: 3.1.1
optional: true
ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 22.13.0
acorn: 8.14.0
acorn-walk: 8.3.4
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 5.7.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optional: true
ts-pattern@5.6.2: {}
tsconfck@3.1.4(typescript@5.7.3):
@@ -20167,29 +20124,6 @@ snapshots:
- supports-color
- ts-node
tsup@6.7.0(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3):
dependencies:
bundle-require: 4.2.1(esbuild@0.17.19)
cac: 6.7.14
chokidar: 3.6.0
debug: 4.4.0
esbuild: 0.17.19
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
postcss-load-config: 3.1.4(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))
resolve-from: 5.0.0
rollup: 3.29.5
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tree-kill: 1.2.2
optionalDependencies:
postcss: 8.5.1
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
- ts-node
tsutils@3.21.0(typescript@5.7.3):
dependencies:
tslib: 1.14.1

View File

@@ -0,0 +1,3 @@
{
"tailwindCSS.experimental.configFile": "packages/ui/src/styles/globals.css"
}