Compare commits

...

35 Commits

Author SHA1 Message Date
shadcn
319f7f9419 chore: build external 2025-07-14 20:06:47 +04:00
shadcn
e6bc16461a feat: poc 2025-07-14 20:02:02 +04:00
shadcn
f85ca066dc deps: update 2025-07-11 18:41:06 +04:00
github-actions[bot]
54e66d4450 chore(release): version packages (#7758)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-11 18:23:59 +04:00
shadcn
6c341c16ae feat: fix safe target and add docs (#7795)
* feat: fix safe target and add docs

* chore: add changeset

* fix: changelog
2025-07-11 18:19:34 +04:00
shadcn
06d03d64f4 feat(shadcn): add support for universal registry item (#7782)
* feat(shadcn): add support for universal registry item

* chore: changeset
2025-07-10 20:17:45 +04:00
shadcn
6407a3b330 fix: chart 2025-07-09 15:52:31 +04:00
shadcn
96b15f6090 feat: update command menu pages and filtering (#7771) 2025-07-09 12:43:50 +04:00
shadcn
2fe9cf6d26 fix: pin recharts until we upgrade to v3 (#7769)
* fix: pin recharts

* docs: add v3 callout
2025-07-09 12:10:40 +04:00
Ehsanullah Haidary
728cb4cfa5 fix(component) Date Picker Dropdown values not visible in chrome, opera and edge (#7724)
* fix(component) Date Picker Dropdown values not visible in chrome, opera and edge

* fix(component) bg-popover added to dropdown in calendar.tsx to fix Date Picker Dropdown values not visible

* chore: build registry

* style: fix

---------

Co-authored-by: shadcn <m@shadcn.com>
2025-07-09 11:43:15 +04:00
shadcn
db93787712 feat(shadcn): implement registry safe path (#7757)
* feat(shadcn): implement registry safe path

* chore: changeset

* style(shadcn): formatting

* fix
2025-07-08 20:25:14 +04:00
shadcn
1cdd6c1645 Merge branch 'main' of github.com:shadcn-ui/ui 2025-07-07 21:03:25 +04:00
shadcn
4983c6e1f4 chore: changelog 2025-07-07 21:03:06 +04:00
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
98 changed files with 2767 additions and 297 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

4
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
@@ -45,3 +46,6 @@ next-env.d.ts
.contentlayer
.content-collections
.source
# Generated data
.data/

View File

@@ -0,0 +1,150 @@
import { promises as fs } from "fs"
import path from "path"
import type { Orama } from "@orama/orama"
import { create, load, search } from "@orama/orama"
import { registryItemSchema } from "shadcn/registry"
import { z } from "zod"
type RegistryItem = z.infer<typeof registryItemSchema>
const searchSchema = {
name: "string",
description: "string",
type: "string",
author: "string",
url: "string",
registryName: "string",
} as const
let searchDb: Orama<typeof searchSchema> | null = null
async function getSearchDb() {
if (searchDb) return searchDb
try {
const indexPath = path.join(
process.cwd(),
".data",
"external-registries-index.json"
)
const indexData = await fs.readFile(indexPath, "utf-8")
const savedDb = JSON.parse(indexData)
searchDb = await create({
schema: searchSchema,
})
await load(searchDb, savedDb)
return searchDb
} catch (error) {
console.error("Failed to load search index:", error)
return null
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const query = searchParams.get("q") || ""
const limit = parseInt(searchParams.get("limit") || "20")
const offset = parseInt(searchParams.get("offset") || "0")
if (!query) {
// Return all items if no query
const registryPath = path.join(
process.cwd(),
".data",
"external-registries.json"
)
try {
const data = await fs.readFile(registryPath, "utf-8")
const registry = JSON.parse(data)
const items = registry.items.slice(offset, offset + limit)
return Response.json(
{
items,
total: registry.items.length,
hasMore: offset + limit < registry.items.length,
},
{
headers: {
"Cache-Control":
"public, s-maxage=3600, stale-while-revalidate=86400",
},
}
)
} catch {
return Response.json({
items: [],
total: 0,
hasMore: false,
})
}
}
// Search using Orama
const db = await getSearchDb()
if (!db) {
// No search index - return empty results
return Response.json({
items: [],
total: 0,
hasMore: false,
})
}
const results = await search(db, {
term: query,
limit,
offset,
})
console.log(`Search query: "${query}", found ${results.count} results`)
// Load the full registry to get complete item data
const registryPath = path.join(
process.cwd(),
".data",
"external-registries.json"
)
try {
const data = await fs.readFile(registryPath, "utf-8")
const registry = JSON.parse(data)
// Map search results to full items
const items = results.hits
.map((hit) => {
return registry.items.find(
(item: RegistryItem) => item.name === hit.document.name
)
})
.filter(Boolean)
return Response.json(
{
items,
total: results.count,
hasMore: offset + limit < results.count,
},
{
headers: {
"Cache-Control":
"public, s-maxage=3600, stale-while-revalidate=86400",
},
}
)
} catch {
return Response.json({
items: [],
total: 0,
hasMore: false,
})
}
} catch (error) {
console.error("Search API error:", error)
return Response.json({ error: "Internal server error" }, { status: 500 })
}
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { META_THEME_COLORS, siteConfig } from "@/lib/config"
import { fontVariables } from "@/lib/fonts"
@@ -88,16 +89,18 @@ export default function RootLayout({
fontVariables
)}
>
<ThemeProvider>
<LayoutProvider>
<ActiveThemeProvider>
{children}
<TailwindIndicator />
<Toaster position="top-center" />
<Analytics />
</ActiveThemeProvider>
</LayoutProvider>
</ThemeProvider>
<NuqsAdapter>
<ThemeProvider>
<LayoutProvider>
<ActiveThemeProvider>
{children}
<TailwindIndicator />
<Toaster position="top-center" />
<Analytics />
</ActiveThemeProvider>
</LayoutProvider>
</ThemeProvider>
</NuqsAdapter>
</body>
</html>
)

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

@@ -36,11 +36,13 @@ export function CommandMenu({
tree,
colors,
blocks,
navItems,
...props
}: DialogProps & {
tree: typeof source.pageTree
colors: ColorPalette[]
blocks?: { name: string; description: string; categories: string[] }[]
navItems?: { href: string; label: string }[]
}) {
const router = useRouter()
const isMac = useIsMac()
@@ -162,12 +164,45 @@ export function CommandMenu({
<DialogTitle>Search documentation...</DialogTitle>
<DialogDescription>Search for a command to run...</DialogDescription>
</DialogHeader>
<Command className="**:data-[slot=command-input-wrapper]:bg-input/50 **:data-[slot=command-input-wrapper]:border-input rounded-none bg-transparent **:data-[slot=command-input]:!h-9 **:data-[slot=command-input]:py-0 **:data-[slot=command-input-wrapper]:mb-0 **:data-[slot=command-input-wrapper]:!h-9 **:data-[slot=command-input-wrapper]:rounded-md **:data-[slot=command-input-wrapper]:border">
<Command
className="**:data-[slot=command-input-wrapper]:bg-input/50 **:data-[slot=command-input-wrapper]:border-input rounded-none bg-transparent **:data-[slot=command-input]:!h-9 **:data-[slot=command-input]:py-0 **:data-[slot=command-input-wrapper]:mb-0 **:data-[slot=command-input-wrapper]:!h-9 **:data-[slot=command-input-wrapper]:rounded-md **:data-[slot=command-input-wrapper]:border"
filter={(value, search, keywords) => {
const extendValue = value + " " + (keywords?.join(" ") || "")
if (extendValue.toLowerCase().includes(search.toLowerCase())) {
return 1
}
return 0
}}
>
<CommandInput placeholder="Search documentation..." />
<CommandList className="no-scrollbar min-h-80 scroll-pt-2 scroll-pb-1.5">
<CommandEmpty className="text-muted-foreground py-12 text-center text-sm">
No results found.
</CommandEmpty>
{navItems && navItems.length > 0 && (
<CommandGroup
heading="Pages"
className="!p-0 [&_[cmdk-group-heading]]:scroll-mt-16 [&_[cmdk-group-heading]]:!p-3 [&_[cmdk-group-heading]]:!pb-1"
>
{navItems.map((item) => (
<CommandMenuItem
key={item.href}
value={`Navigation ${item.label}`}
keywords={["nav", "navigation", item.label.toLowerCase()]}
onHighlight={() => {
setSelectedType("page")
setCopyPayload("")
}}
onSelect={() => {
runCommand(() => router.push(item.href))
}}
>
<IconArrowRight />
{item.label}
</CommandMenuItem>
))}
</CommandGroup>
)}
{tree.children.map((group) => (
<CommandGroup
key={group.$id}

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

@@ -0,0 +1,236 @@
"use client"
import * as React from "react"
import { ArrowUpRightIcon, Loader2Icon, SearchIcon } from "lucide-react"
import { useQueryState } from "nuqs"
import { registryItemSchema } from "shadcn/registry"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Input } from "@/registry/new-york-v4/ui/input"
import { Skeleton } from "@/registry/new-york-v4/ui/skeleton"
export function ComponentsCommunitySearch({
className,
}: React.ComponentProps<"div">) {
return (
<div className={cn("grid gap-4", className)}>
<ComponentsCommunitySearchForm />
<ComponentsCommunitySearchResults />
</div>
)
}
interface SearchResponse {
items: z.infer<typeof registryItemSchema>[]
total: number
hasMore: boolean
}
function ComponentsCommunitySearchForm() {
const [search, setSearch] = useQueryState("q", {
defaultValue: "",
throttleMs: 150,
})
const [isSearching, setIsSearching] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
const activeElement = document.activeElement
const isInputFocused =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement
if (!isInputFocused) {
e.preventDefault()
inputRef.current?.focus()
}
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [])
return (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
ref={inputRef}
placeholder="Search components..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setIsSearching(true)
setTimeout(() => setIsSearching(false), 300)
}}
className="pr-9 pl-9 shadow-none"
/>
{isSearching && (
<Loader2Icon className="text-muted-foreground absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin" />
)}
</div>
)
}
function ComponentsCommunityResults({
items,
}: {
items: z.infer<typeof registryItemSchema>[]
}) {
if (items.length === 0) {
return (
<div className="p-6 text-center text-sm">
<p className="text-muted-foreground text-balance">
No components found for this search. Try a different search term.
</p>
</div>
)
}
return (
<div className="grid gap-1">
{items.map((item, index) => (
<a
key={`${item.type}-${item.name}-${index}`}
href={`${item.meta?.url ?? ""}?utm_source=shadcn-ui&utm_medium=referral&utm_campaign=components-community`}
target="_blank"
rel="noopener noreferrer"
className="group hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 flex h-16 flex-col gap-1 rounded-md p-3 transition-colors outline-none focus-visible:ring-[3px]"
title={`${item.name} - ${item.description}`}
>
<div className="flex items-center gap-1 leading-none font-medium underline-offset-4">
{item.name}{" "}
{item.meta?.registryName && (
<div className="text-muted-foreground ml-auto flex items-center gap-1 text-xs opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{item.meta.registryName}
<ArrowUpRightIcon className="size-3" />
</div>
)}
</div>
{item.description && (
<div className="text-muted-foreground line-clamp-1 max-w-[80%] text-sm">
{item.description}
</div>
)}
</a>
))}
</div>
)
}
function ComponentsCommunitySkeleton() {
return (
<div className="grid gap-1">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="flex h-16 flex-col gap-1 rounded-md p-3">
<Skeleton className="h-4 w-32 rounded-md" />
<Skeleton className="h-4 w-full max-w-[90%] rounded-md" />
</div>
))}
</div>
)
}
function ComponentsCommunitySearchResults() {
const [search] = useQueryState("q", { defaultValue: "" })
const [items, setItems] = React.useState<
z.infer<typeof registryItemSchema>[]
>([])
const [loading, setLoading] = React.useState(true)
const [hasMore, setHasMore] = React.useState(false)
const [offset, setOffset] = React.useState(0)
const abortControllerRef = React.useRef<AbortController | null>(null)
const searchCacheRef = React.useRef<Map<string, SearchResponse>>(new Map())
const performSearch = React.useCallback(
async (query: string, currentOffset = 0) => {
const cacheKey = `${query}:${currentOffset}`
const cached = searchCacheRef.current.get(cacheKey)
if (cached && currentOffset === 0) {
setItems(cached.items)
setHasMore(cached.hasMore)
setOffset(0)
return
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const controller = new AbortController()
abortControllerRef.current = controller
setLoading(true)
try {
const params = new URLSearchParams({
q: query,
limit: "20",
offset: currentOffset.toString(),
})
const response = await fetch(`/api/search/community?${params}`, {
signal: controller.signal,
})
if (!response.ok) {
throw new Error("Search failed")
}
const data: SearchResponse = await response.json()
searchCacheRef.current.set(cacheKey, data)
if (currentOffset === 0) {
setItems(data.items)
} else {
setItems((prev) => [...prev, ...data.items])
}
setHasMore(data.hasMore)
setOffset(currentOffset)
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
console.error("Search error:", error)
}
} finally {
setLoading(false)
}
},
[]
)
React.useEffect(() => {
performSearch(search, 0)
}, [search, performSearch])
const loadMore = React.useCallback(() => {
if (!loading && hasMore) {
performSearch(search, offset + 20)
}
}, [search, offset, loading, hasMore, performSearch])
if (loading && items.length === 0) {
return <ComponentsCommunitySkeleton />
}
return (
<>
<ComponentsCommunityResults items={items} />
{hasMore && (
<div className="flex justify-center pt-4">
<Button
onClick={loadMore}
disabled={loading}
variant="secondary"
size="sm"
>
{loading ? "Loading..." : "Load more"}
</Button>
</div>
)}
</>
)
}

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

@@ -41,7 +41,11 @@ export function SiteHeader() {
<MainNav items={siteConfig.navItems} className="hidden lg:flex" />
<div className="ml-auto flex items-center gap-2 md:flex-1 md:justify-end">
<div className="hidden w-full flex-1 md:flex md:w-auto md:flex-none">
<CommandMenu tree={pageTree} colors={colors} />
<CommandMenu
tree={pageTree}
colors={colors}
navItems={siteConfig.navItems}
/>
</div>
<Separator
orientation="vertical"

View File

@@ -4,6 +4,33 @@ description: Latest updates and announcements.
toc: false
---
## July 2025 - Universal Registry Items
We've added support for universal registry items. This allows you to create registry items that can be distributed to any project i.e. no framework, no components.json, no tailwind, no react required.
This new registry item type unlocks a lot of new workflows. You can now distribute code, config, rules, docs, anything to any code project.
See the [docs](/docs/registry/examples) for more details and examples.
## July 2025 - Local File Support
The shadcn CLI now supports local files. Initialize projects and add components, themes, hooks, utils and more from local JSON files.
```bash
# Initialize a project from a local file
npx shadcn init ./template.json
# Add a component from a local file
npx shadcn add ./block.json
```
This feature enables powerful new workflows:
- **Zero setup** - No remote registries required
- **Faster development** - Test registry items locally before publishing
- **Enhanced workflow for agents and MCP** - Generate and run registry items locally
- **Private components** - Keep proprietary components local and private.
## 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`.

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

@@ -4,9 +4,15 @@ description: Beautiful charts. Built using Recharts. Copy and paste into your ap
component: true
---
<Callout>
**Note:** We're working on upgrading to Recharts v3. In the meantime, if you'd like to start testing v3, see the code in the comment [here](https://github.com/shadcn-ui/ui/issues/7669#issuecomment-2998299159). We'll have an official release soon.
</Callout>
<ComponentPreview
name="chart-bar-interactive"
className="theme-blue -mt-4 [&_.preview]:p-0 [&_.preview]:lg:min-h-[404px] [&_.preview>div]:w-full [&_.preview>div]:border-none [&_.preview>div]:shadow-none"
className="theme-blue [&_.preview]:h-auto [&_.preview]:p-0 [&_.preview]:lg:min-h-[404px] [&_.preview>div]:w-full [&_.preview>div]:border-none [&_.preview>div]:shadow-none"
hideCode
/>

View File

@@ -0,0 +1,12 @@
---
title: Community
description: Discover components from the community.
---
import { ComponentsCommunitySearch } from "@/components/components-community"
The following components are created and maintained by the community. They are compatible with shadcn/ui primitives and works with the CLI.
You will be taken to the external component page for preview, documentation and installation instructions.
<ComponentsCommunitySearch className="mt-6" />

View File

@@ -3,4 +3,9 @@ title: Components
description: Here you can find all the components available in the library. We are working on adding more components.
---
<Callout className="mb-6">
Looking for more components? Check out the [Components
Community](/docs/components/community) page.
</Callout>
<ComponentsList />

View File

@@ -0,0 +1,4 @@
{
"title": "Components",
"pages": ["!community", "..."]
}

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.
@@ -354,3 +368,70 @@ Note: you need to define both `@keyframes` in css and `theme` in cssVars to use
}
}
```
## Universal Items
As of `2.9.0`, you can create universal items that can be installed without framework detection or components.json.
To make an item universal i.e framework agnostic, all the files in the item must have an explicit target.
Here's an example of a registry item that installs custom Cursor rules for _python_:
```json title=".cursor/rules/custom-python.mdc" showLineNumbers {9}
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "python-rules",
"type": "registry:item",
"files": [
{
"path": "/path/to/your/registry/default/custom-python.mdc",
"type": "registry:file",
"target": "~/.cursor/rules/custom-python.mdc",
"content": "..."
}
]
}
```
Here's another example for installation custom ESLint config:
```json title=".eslintrc.json" showLineNumbers {9}
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "my-eslint-config",
"type": "registry:item",
"files": [
{
"path": "/path/to/your/registry/default/custom-eslint.json",
"type": "registry:file",
"target": "~/.eslintrc.json",
"content": "..."
}
]
}
```
You can also have a universal item that installs multiple files:
```json title="my-custom-starter-template.json" showLineNumbers {9}
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "my-custom-start-template",
"type": "registry:item",
dependencies: ["better-auth"]
"files": [
{
"path": "/path/to/file-01.json",
"type": "registry:file",
"target": "~/file-01.json",
"content": "..."
},
{
"path": "/path/to/file-02.vue",
"type": "registry:file",
"target": "~/pages/file-02.vue",
"content": "..."
}
]
}
```

View File

@@ -1,15 +1,14 @@
---
title: Registry
description: Run your own component registry.
description: Run your own code registry.
---
<Callout>
**Note:** This feature is currently experimental. Help us improve it by
testing it out and sending feedback. If you have any questions, please [reach
out to us](https://github.com/shadcn-ui/ui/discussions).
</Callout>
You can use the `shadcn` CLI to run your own code registry. Running your own registry allows you to distribute your custom components, hooks, pages, config, rules and other files to any project.
You can use the `shadcn` CLI to run your own component registry. Running your own registry allows you to distribute your custom components, hooks, pages, and other files to any React project.
<Callout>
**Note:** The registry works with any project type and any framework, and is
not limited to React.
</Callout>
<figure className="flex flex-col gap-4">
<Image
@@ -27,12 +26,10 @@ You can use the `shadcn` CLI to run your own component registry. Running your ow
className="mt-6 hidden w-full overflow-hidden rounded-lg border shadow-sm dark:block"
/>
<figcaption className="text-center text-sm text-gray-500">
Distribute code to any React project.
A distribution system for code
</figcaption>
</figure>
Registry items are automatically compatible with the `shadcn` CLI and `Open in v0`.
## Requirements
You are free to design and host your custom registry as you see fit. The only requirement is that your registry items must be valid JSON files that conform to the [registry-item schema specification](/docs/registry/registry-item-json).

View File

@@ -107,6 +107,7 @@ The following types are supported:
| `registry:file` | Use for miscellaneous files. |
| `registry:style` | Use for registry styles. eg. `new-york` |
| `registry:theme` | Use for themes. |
| `registry:item` | Use for universal registry items. |
### author
@@ -260,11 +261,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

@@ -33,6 +33,10 @@ export const siteConfig = {
href: "/colors",
label: "Colors",
},
{
href: "/docs/components/community",
label: "Community",
},
],
}

View File

@@ -68,6 +68,11 @@ const nextConfig = {
destination: "/view/:name",
permanent: true,
},
{
source: "/community",
destination: "/docs/components/community",
permanent: false,
},
]
},
}

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev --turbopack --port 4000",
"build": "pnpm --filter=shadcn build && next build",
"build": "pnpm --filter=shadcn build && pnpm build:external && next build",
"start": "next start --port 4000",
"lint": "next lint",
"lint:fix": "next lint --fix",
@@ -14,6 +14,7 @@
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"registry:build": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,json,mdx}\" --cache",
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
"build:external": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-external-registry.mts",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
@@ -23,6 +24,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^8.2.0",
"@hookform/resolvers": "^3.10.0",
"@orama/orama": "^3.1.11",
"@radix-ui/react-accessible-icon": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.5",
@@ -77,6 +79,7 @@
"motion": "^12.12.1",
"next": "15.3.1",
"next-themes": "0.4.6",
"nuqs": "^2.4.3",
"postcss": "^8.5.1",
"react": "19.1.0",
"react-day-picker": "^9.7.0",
@@ -86,7 +89,7 @@
"recharts": "2.15.1",
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"shadcn": "2.6.2",
"shadcn": "2.9.0",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.1",

View File

@@ -180,7 +180,7 @@
"name": "chart",
"type": "registry:ui",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
"type": "registry:ui",
"author": "shadcn (https://ui.shadcn.com)",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

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

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"name": "chart",
"type": "registry:ui",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
"type": "registry:ui",
"author": "shadcn (https://ui.shadcn.com)",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

View File

@@ -17,7 +17,8 @@
"registry:theme",
"registry:page",
"registry:file",
"registry:style"
"registry:style",
"registry:item"
],
"description": "The type of the item. This is used to determine the type and target path of the item when resolved for a project."
},
@@ -79,7 +80,8 @@
"registry:theme",
"registry:page",
"registry:file",
"registry:style"
"registry:style",
"registry:item"
],
"description": "The type of the file. This is used to determine the type of the file when resolved for a project."
},

View File

@@ -169,7 +169,7 @@
"name": "chart",
"type": "registry:ui",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

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

@@ -73,7 +73,10 @@ function Calendar({
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"

View File

@@ -0,0 +1,5 @@
[
"https://api.npoint.io/db04a255782ab866fcd3",
"https://api.npoint.io/8183a39e7dad9bb86e78",
"https://api.npoint.io/e69a11a4d660bb12c0de"
]

View File

@@ -155,7 +155,7 @@ export const ui: Registry["items"] = [
},
],
registryDependencies: ["card"],
dependencies: ["recharts", "lucide-react"],
dependencies: ["recharts@2.15.4", "lucide-react"],
},
{
name: "checkbox",

View File

@@ -0,0 +1,171 @@
import { promises as fs } from "fs"
import path from "path"
import { create, insertMultiple, save } from "@orama/orama"
import { z } from "zod"
// Schema for registries.json - just an array of URLs
const RegistriesConfigSchema = z.array(z.string().url())
// Schema for registry items (matching the public schema)
const RegistryItemSchema = z.object({
name: z.string(),
type: z.enum([
"registry:lib",
"registry:block",
"registry:component",
"registry:ui",
"registry:hook",
"registry:theme",
"registry:page",
"registry:file",
"registry:style",
"registry:item",
]),
description: z.string().optional(),
author: z.string().optional(),
dependencies: z.array(z.string()).optional(),
devDependencies: z.array(z.string()).optional(),
registryDependencies: z.array(z.string()).optional(),
files: z.array(z.any()).optional(),
categories: z.array(z.string()).optional(),
meta: z.record(z.any()).optional(),
})
const RegistrySchema = z.object({
name: z.string(),
homepage: z.string(),
items: z.array(RegistryItemSchema),
})
type Registry = z.infer<typeof RegistrySchema>
type RegistryItem = z.infer<typeof RegistryItemSchema>
interface ProcessedRegistry {
url: string
data: Registry
items: RegistryItem[]
fetchedAt: string
error?: string
}
async function fetchRegistry(url: string): Promise<ProcessedRegistry> {
console.log(`📥 Fetching registry from ${url}`)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const validated = RegistrySchema.parse(data)
// Items are already in the correct format
const items = validated.items
console.log(`✅ Successfully fetched ${validated.items.length} items from ${validated.name}`)
return {
url,
data: validated,
items: items,
fetchedAt: new Date().toISOString(),
}
} catch (error) {
console.error(`❌ Failed to fetch ${url}:`, error)
return {
url,
data: { name: "Unknown", homepage: url, items: [] },
items: [],
fetchedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
async function buildExternalRegistry() {
console.log("🔨 Building external registry index...")
// Read registries config
const configPath = path.join(process.cwd(), "registry", "registry-external.json")
const configContent = await fs.readFile(configPath, "utf-8")
const config = RegistriesConfigSchema.parse(JSON.parse(configContent))
// Output will go to content directory
// Fetch all registries
const results = await Promise.all(
config.map(url => fetchRegistry(url))
)
// Combine all items from all registries, adding registry name to each item
const allItems = results.flatMap(r =>
r.items.map(item => ({
...item,
meta: {
...item.meta,
registryName: r.data.name,
registryHomepage: r.data.homepage,
}
}))
)
// Create a registry following the standard schema
const registry = {
name: "External Registries",
homepage: "https://ui.shadcn.com/docs/components",
items: allItems,
}
// Create data directory
const dataDir = path.join(process.cwd(), ".data")
await fs.mkdir(dataDir, { recursive: true })
// Write registry to data directory
const outputPath = path.join(dataDir, "external-registries.json")
await fs.writeFile(outputPath, JSON.stringify(registry, null, 2))
// Create search index
console.log("🔍 Building search index...")
const searchDb = await create({
schema: {
name: 'string',
description: 'string',
type: 'string',
author: 'string',
url: 'string',
registryName: 'string',
},
components: {
tokenizer: {
stemming: false, // Disable stemming for faster indexing
stopWords: false, // Disable stop words for component names
},
},
})
// Prepare items for indexing
const searchItems = allItems.map(item => ({
name: item.name,
description: item.description || '',
type: item.type,
author: item.author || '',
url: item.meta?.url || '',
registryName: item.meta?.registryName || '',
}))
await insertMultiple(searchDb, searchItems)
// Save search index
const indexPath = path.join(dataDir, "external-registries-index.json")
const index = await save(searchDb)
await fs.writeFile(indexPath, JSON.stringify(index))
console.log(`✨ External registry built successfully!`)
console.log(`📊 Total registries: ${results.length}`)
console.log(`📦 Total items: ${allItems.length}`)
console.log(`📍 Registry saved to: ${outputPath}`)
console.log(`🔍 Search index saved to: ${indexPath}`)
}
buildExternalRegistry().catch(console.error)

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.2",
"shadcn": "2.9.0",
"sharp": "^0.32.6",
"sonner": "^1.2.3",
"swr": "2.2.6-beta.3",

View File

@@ -180,7 +180,7 @@
"name": "chart",
"type": "registry:ui",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
"type": "registry:ui",
"author": "shadcn (https://ui.shadcn.com)",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

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

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"name": "chart",
"type": "registry:ui",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
"type": "registry:ui",
"author": "shadcn (https://ui.shadcn.com)",
"dependencies": [
"recharts",
"recharts@2.15.4",
"lucide-react"
],
"registryDependencies": [

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

@@ -73,7 +73,10 @@ function Calendar({
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"

View File

@@ -73,7 +73,10 @@ function Calendar({
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"

View File

@@ -155,7 +155,7 @@ export const ui: Registry["items"] = [
},
],
registryDependencies: ["card"],
dependencies: ["recharts", "lucide-react"],
dependencies: ["recharts@2.15.4", "lucide-react"],
},
{
name: "checkbox",

View File

@@ -35,6 +35,7 @@
"www:build": "pnpm --filter=www build",
"v4:dev": "pnpm --filter=v4 dev",
"v4:build": "pnpm --filter=v4 build",
"build:external": "pnpm --filter=v4 build:external",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"preview": "turbo run preview",

View File

@@ -1,5 +1,51 @@
# @shadcn/ui
## 2.9.0
### Minor Changes
- [#7782](https://github.com/shadcn-ui/ui/pull/7782) [`06d03d64f437b543bf5fa07ccbc559f285538ffd`](https://github.com/shadcn-ui/ui/commit/06d03d64f437b543bf5fa07ccbc559f285538ffd) Thanks [@shadcn](https://github.com/shadcn)! - add universal registry items support
### Patch Changes
- [#7795](https://github.com/shadcn-ui/ui/pull/7795) [`6c341c16aeaf5ade177a4a1ba4fb9afcd33d5fee`](https://github.com/shadcn-ui/ui/commit/6c341c16aeaf5ade177a4a1ba4fb9afcd33d5fee) Thanks [@shadcn](https://github.com/shadcn)! - fix safe target handling
- [#7757](https://github.com/shadcn-ui/ui/pull/7757) [`db93787712fe51346bf87dbae8b4cf4e38ed8c27`](https://github.com/shadcn-ui/ui/commit/db93787712fe51346bf87dbae8b4cf4e38ed8c27) Thanks [@shadcn](https://github.com/shadcn)! - implement registry path validation
## 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

View File

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

View File

@@ -1,12 +1,18 @@
import fs from "fs"
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,
isUniversalRegistryItem,
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"
import { getConfig } from "@/src/utils/get-config"
import { createConfig, getConfig } from "@/src/utils/get-config"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
@@ -46,10 +52,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(
@@ -80,10 +83,14 @@ export const add = new Command()
})
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
let registryItem: any = null
if (components.length > 0 && isUrl(components[0])) {
const item = await getRegistryItem(components[0], "")
itemType = item?.type
if (
components.length > 0 &&
(isUrl(components[0]) || isLocalFile(components[0]))
) {
registryItem = await getRegistryItem(components[0], "")
itemType = registryItem?.type
}
if (
@@ -129,6 +136,22 @@ export const add = new Command()
}
}
if (isUniversalRegistryItem(registryItem)) {
// Universal items only cares about the cwd.
if (!fs.existsSync(options.cwd)) {
throw new Error(`Directory ${options.cwd} does not exist`)
}
const minimalConfig = createConfig({
resolvedPaths: {
cwd: options.cwd,
},
})
await addComponents(options.components, minimalConfig, options)
return
}
let { errors, config } = await preFlightAdd(options)
// No components.json file. Prompt the user to run init.

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

@@ -88,7 +88,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from "radix-ui";
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from "radix-ui"
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
@@ -109,7 +109,7 @@ export const DialogRoot = Root
export const DialogTrigger = Trigger
export const SelectContent = Content`
const expected = `import { Root, Trigger, Content } from "radix-ui";
const expected = `import { Root, Trigger, Content } from "radix-ui"
export const DialogRoot = Root
export const DialogTrigger = Trigger
@@ -131,7 +131,7 @@ import { useState } from "react"
export const Dialog = DialogPrimitive.Root
export const Select = SelectRoot`
const expected = `import { Dialog as DialogPrimitive, Root as SelectRoot } from "radix-ui";
const expected = `import { Dialog as DialogPrimitive, Root as SelectRoot } from "radix-ui"
import { useState } from "react"
@@ -184,7 +184,7 @@ export const Dialog = DialogPrimitive.Root`
const expected = `"use client"
import { Dialog as DialogPrimitive } from "radix-ui";
import { Dialog as DialogPrimitive } from "radix-ui"
import { useState } from "react"
export const Dialog = DialogPrimitive.Root`
@@ -206,7 +206,7 @@ export const Dialog = DialogPrimitive.Root`
const expected = `"use client"
import { DropdownMenu as DropdownMenuPrimitive, Dialog as DialogPrimitive } from "radix-ui";
import { DropdownMenu as DropdownMenuPrimitive, Dialog as DialogPrimitive } from "radix-ui"
import { useState } from "react"
@@ -228,7 +228,7 @@ import * as SelectPrimitive from '@radix-ui/react-select'
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from 'radix-ui';
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from 'radix-ui'
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
@@ -248,7 +248,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from 'radix-ui';
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from 'radix-ui'
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
@@ -270,7 +270,7 @@ export type MyDialogProps = ComponentProps
export type MySelectProps = SelectProps
export const Dialog = DialogPrimitive.Root`
const expected = `import { type ComponentProps, type SelectProps, Root, Dialog as DialogPrimitive } from "radix-ui";
const expected = `import { type ComponentProps, type SelectProps, Root, Dialog as DialogPrimitive } from "radix-ui"
export type MyDialogProps = ComponentProps
export type MySelectProps = SelectProps
@@ -291,7 +291,7 @@ import { Root, Trigger } from "@radix-ui/react-dialog"
export type Props = DialogProps
export const DialogRoot = Root`
const expected = `import { type DialogProps, Root, Trigger } from "radix-ui";
const expected = `import { type DialogProps, Root, Trigger } from "radix-ui"
export type Props = DialogProps
export const DialogRoot = Root`
@@ -308,7 +308,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
export type Props = DialogTypes.ComponentProps
export const Dialog = DialogPrimitive.Root`
const expected = `import { type Dialog as DialogTypes, Dialog as DialogPrimitive } from "radix-ui";
const expected = `import { type Dialog as DialogTypes, Dialog as DialogPrimitive } from "radix-ui"
export type Props = DialogTypes.ComponentProps
export const Dialog = DialogPrimitive.Root`
@@ -327,7 +327,7 @@ export const Dialog = DialogPrimitive.Root
export const ChevronDown = ChevronDownIcon`
const expected = `import { ChevronDownIcon, Cross2Icon } from "@radix-ui/react-icons"
import { Dialog as DialogPrimitive, Root } from "radix-ui";
import { Dialog as DialogPrimitive, Root } from "radix-ui"
export const Dialog = DialogPrimitive.Root
export const ChevronDown = ChevronDownIcon`
@@ -349,7 +349,7 @@ export type MyIconProps = IconProps
export type MyDialogProps = ComponentProps`
const expected = `import type { IconProps } from "@radix-ui/react-icons/dist/types"
import { type ComponentProps, Root } from "radix-ui";
import { type ComponentProps, Root } from "radix-ui"
export type MyIconProps = IconProps
export type MyDialogProps = ComponentProps`
@@ -373,7 +373,7 @@ export type Props = IconProps`
const expected = `import * as Icons from "@radix-ui/react-icons"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import type { IconProps } from "@radix-ui/react-icons/dist/types"
import { Dialog as DialogPrimitive, Root } from "radix-ui";
import { Dialog as DialogPrimitive, Root } from "radix-ui"
export const Dialog = DialogPrimitive.Root
export const Icon = ChevronDownIcon
@@ -401,7 +401,7 @@ import {
export const DialogRoot = Root
export const SelectValue = Value`
const expected = `import { Root, Trigger, Content, Value, Item } from "radix-ui";
const expected = `import { Root, Trigger, Content, Value, Item } from "radix-ui"
export const DialogRoot = Root
export const SelectValue = Value`
@@ -425,7 +425,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"
export const Dialog = DialogRoot
export const Select = SelectPrimitive.Root`
const expected = `import { Root as DialogRoot, Trigger, Content, Select as SelectPrimitive } from "radix-ui";
const expected = `import { Root as DialogRoot, Trigger, Content, Select as SelectPrimitive } from "radix-ui"
export const Dialog = DialogRoot
export const Select = SelectPrimitive.Root`
@@ -450,7 +450,7 @@ import {
export type Props = DialogProps`
const expected = `import { type ComponentProps, type DialogProps, type SelectProps, Root } from "radix-ui";
const expected = `import { type ComponentProps, type DialogProps, type SelectProps, Root } from "radix-ui"
export type Props = DialogProps`
@@ -472,7 +472,7 @@ export type Props = DialogProps`
export const DialogRoot = Root`
const expected = `import { Root, Trigger, Content } from "radix-ui";
const expected = `import { Root, Trigger, Content } from "radix-ui"
export const DialogRoot = Root`
@@ -490,7 +490,7 @@ export const DialogRoot = Root`
export const DialogRoot = Root`
const expected = `import { Root, Trigger, Content } from "radix-ui";
const expected = `import { Root, Trigger, Content } from "radix-ui"
export const DialogRoot = Root`
@@ -532,7 +532,7 @@ export const Accordion = AccordionPrimitive.Root
export const AlertDialog = AlertDialogPrimitive.Root
export const Button = Slot`
const expected = `import { Accordion as AccordionPrimitive, AlertDialog as AlertDialogPrimitive, AspectRatio as AspectRatioPrimitive, Avatar as AvatarPrimitive, Slot as SlotPrimitive, Checkbox as CheckboxPrimitive, Collapsible as CollapsiblePrimitive, ContextMenu as ContextMenuPrimitive, Dialog as DialogPrimitive, DropdownMenu as DropdownMenuPrimitive, HoverCard as HoverCardPrimitive, Label as LabelPrimitive, Menubar as MenubarPrimitive, NavigationMenu as NavigationMenuPrimitive, Popover as PopoverPrimitive, Progress as ProgressPrimitive, RadioGroup as RadioGroupPrimitive, ScrollArea as ScrollAreaPrimitive, Select as SelectPrimitive, Separator as SeparatorPrimitive, Slider as SliderPrimitive, Switch as SwitchPrimitive, Tabs as TabsPrimitive, Toggle as TogglePrimitive, ToggleGroup as ToggleGroupPrimitive, Tooltip as TooltipPrimitive } from "radix-ui";
const expected = `import { Accordion as AccordionPrimitive, AlertDialog as AlertDialogPrimitive, AspectRatio as AspectRatioPrimitive, Avatar as AvatarPrimitive, Slot as SlotPrimitive, Checkbox as CheckboxPrimitive, Collapsible as CollapsiblePrimitive, ContextMenu as ContextMenuPrimitive, Dialog as DialogPrimitive, DropdownMenu as DropdownMenuPrimitive, HoverCard as HoverCardPrimitive, Label as LabelPrimitive, Menubar as MenubarPrimitive, NavigationMenu as NavigationMenuPrimitive, Popover as PopoverPrimitive, Progress as ProgressPrimitive, RadioGroup as RadioGroupPrimitive, ScrollArea as ScrollAreaPrimitive, Select as SelectPrimitive, Separator as SeparatorPrimitive, Slider as SliderPrimitive, Switch as SwitchPrimitive, Tabs as TabsPrimitive, Toggle as TogglePrimitive, ToggleGroup as ToggleGroupPrimitive, Tooltip as TooltipPrimitive } from "radix-ui"
export const Accordion = AccordionPrimitive.Root
export const AlertDialog = AlertDialogPrimitive.Root
@@ -578,7 +578,7 @@ import { cn } from "@/lib/utils"
export const Sheet = SheetPrimitive.Root
export const SheetTrigger = SheetPrimitive.Trigger`
const expected = `import { Dialog as SheetPrimitive } from "radix-ui";
const expected = `import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
export const Sheet = SheetPrimitive.Root
@@ -597,7 +597,7 @@ import { Slot } from "@radix-ui/react-slot"
export const FormLabel = LabelPrimitive.Root
export const FormControl = Slot`
const expected = `import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
const expected = `import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"
export const FormLabel = LabelPrimitive.Root
export const FormControl = SlotPrimitive.Slot`
@@ -664,7 +664,7 @@ export const FormControl = SlotPrimitive.Slot`
expect(result.replacedPackages.sort()).toEqual(allPackages.sort())
// Should be a single unified import from radix-ui
expect(result.content).toContain('from "radix-ui";')
expect(result.content).toContain('from "radix-ui"')
expect(result.content.startsWith("import {")).toBe(true)
expect(result.content).toContain("Slot as SlotPrimitive") // Slot should be aliased as SlotPrimitive
expect(result.content).toContain("Accordion as AccordionPrimitive") // Namespace should be aliased
@@ -685,7 +685,7 @@ const Button = ({ asChild, children }) => {
return <Comp>{children}</Comp>
}`
const expected = `import { Slot as SlotPrimitive } from "radix-ui";
const expected = `import { Slot as SlotPrimitive } from "radix-ui"
const Button = ({ asChild, children }) => {
const Comp = asChild ? SlotPrimitive.Slot : "button"
@@ -708,7 +708,7 @@ const Button = ({ asChild }) => {
return null
}`
const expected = `import { Slot as SlotPrimitive } from "radix-ui";
const expected = `import { Slot as SlotPrimitive } from "radix-ui"
const Button = ({ asChild }) => {
const Comp1 = asChild ? SlotPrimitive.Slot : "button"
@@ -731,7 +731,7 @@ const Button = ({ asChild }) => {
return null
}`
const expected = `import { Slot as SlotComponent } from "radix-ui";
const expected = `import { Slot as SlotComponent } from "radix-ui"
const Button = ({ asChild }) => {
const Comp = asChild ? Slot : "button"
@@ -754,7 +754,7 @@ const Button = ({ asChild }) => {
return <Slot />
}`
const expected = `import { Slot as SlotPrimitive } from "radix-ui";
const expected = `import { Slot as SlotPrimitive } from "radix-ui"
const Button = ({ asChild }) => {
const SlotName = "Slot"
@@ -781,7 +781,7 @@ const Button = ({ asChild, ...props }: ButtonProps) => {
return <Comp {...props} />
}`
const expected = `import { Slot as SlotPrimitive } from "radix-ui";
const expected = `import { Slot as SlotPrimitive } from "radix-ui"
import React from "react"
type ButtonProps = React.ComponentProps<typeof SlotPrimitive.Slot> & {
@@ -811,7 +811,7 @@ const Button = ({ asChild, ...props }: ButtonProps) => {
return <Comp {...props} />
}`
const expected = `import { Slot as SlotPrimitive } from "radix-ui";
const expected = `import { Slot as SlotPrimitive } from "radix-ui"
import { ComponentProps } from "react"
type ButtonProps = ComponentProps<typeof SlotPrimitive.Slot> & {
@@ -827,6 +827,46 @@ const Button = ({ asChild, ...props }: ButtonProps) => {
expect(result.content.trim()).toBe(expected.trim())
expect(result.replacedPackages).toEqual(["@radix-ui/react-slot"])
})
it("should not add double semicolons when import already ends with semicolon", async () => {
const input = `import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as SelectPrimitive from "@radix-ui/react-select";
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from "radix-ui";
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const result = await migrateRadixFile(input)
expect(result.content.trim()).toBe(expected.trim())
expect(result.replacedPackages).toEqual([
"@radix-ui/react-dialog",
"@radix-ui/react-select",
])
})
it("should not add semicolon when original imports don't have semicolons", async () => {
const input = `import * as DialogPrimitive from "@radix-ui/react-dialog"
import * as SelectPrimitive from "@radix-ui/react-select"
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const expected = `import { Dialog as DialogPrimitive, Select as SelectPrimitive } from "radix-ui"
export const Dialog = DialogPrimitive.Root
export const Select = SelectPrimitive.Root`
const result = await migrateRadixFile(input)
expect(result.content.trim()).toBe(expected.trim())
expect(result.replacedPackages).toEqual([
"@radix-ui/react-dialog",
"@radix-ui/react-select",
])
})
})
describe("migrateRadix - package.json updates", () => {

View File

@@ -215,13 +215,15 @@ 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
/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
@@ -235,6 +237,7 @@ export async function migrateRadixFile(
namedImports,
quote,
packageName,
semicolon,
] = match
// Skip react-icons package and any sub-paths (like react-icons/dist/types)
@@ -244,9 +247,10 @@ export async function migrateRadixFile(
linesToRemove.push(fullMatch)
// Use the quote style from the first import
// 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
@@ -301,7 +305,9 @@ export async function migrateRadixFile(
})
.join(", ")
const unifiedImport = `import { ${importList} } from ${quoteStyle}radix-ui${quoteStyle};`
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) => {

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

@@ -13,6 +13,7 @@ export const registryItemTypeSchema = z.enum([
"registry:file",
"registry:theme",
"registry:style",
"registry:item",
// Internal use only
"registry:example",

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"
import { getDependencyFromModuleSpecifier } from "./utils"
import {
getDependencyFromModuleSpecifier,
isLocalFile,
isUniversalRegistryItem,
isUrl,
} from "./utils"
describe("getDependencyFromModuleSpecifier", () => {
it("should return the first part of a non-scoped package with path", () => {
@@ -74,3 +79,195 @@ 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)
})
})
describe("isUniversalRegistryItem", () => {
it("should return true when all files have targets", () => {
const registryItem = {
files: [
{
path: "file1.ts",
target: "src/file1.ts",
type: "registry:lib" as const,
},
{
path: "file2.ts",
target: "src/utils/file2.ts",
type: "registry:lib" as const,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
it("should return false when some files lack targets", () => {
const registryItem = {
files: [
{
path: "file1.ts",
target: "src/file1.ts",
type: "registry:lib" as const,
},
{ path: "file2.ts", target: "", type: "registry:lib" as const },
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when no files have targets", () => {
const registryItem = {
files: [
{ path: "file1.ts", target: "", type: "registry:lib" as const },
{ path: "file2.ts", target: "", type: "registry:lib" as const },
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when files array is empty", () => {
const registryItem = {
files: [],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when files is undefined", () => {
const registryItem = {}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when registryItem is null", () => {
expect(isUniversalRegistryItem(null)).toBe(false)
})
it("should return false when registryItem is undefined", () => {
expect(isUniversalRegistryItem(undefined)).toBe(false)
})
it("should return false when target is null", () => {
const registryItem = {
files: [
{
path: "file1.ts",
target: null as any,
type: "registry:lib" as const,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when target is undefined", () => {
const registryItem = {
files: [{ path: "file1.ts", type: "registry:lib" as const }],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should handle mixed file types correctly", () => {
const registryItem = {
files: [
{
path: "component.tsx",
target: "components/ui/component.tsx",
type: "registry:ui" as const,
},
{
path: "utils.ts",
target: "lib/utils.ts",
type: "registry:lib" as const,
},
{
path: "hook.ts",
target: "hooks/use-something.ts",
type: "registry:hook" as const,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
it("should return true when all targets are non-empty strings", () => {
const registryItem = {
files: [
{ path: "file1.ts", target: " ", type: "registry:lib" as const }, // whitespace is truthy
{ path: "file2.ts", target: "0", type: "registry:lib" as const }, // "0" is truthy
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
it("should handle real-world example with path traversal attempts", () => {
const registryItem = {
files: [
{
path: "malicious.ts",
target: "../../../etc/passwd",
type: "registry:lib" as const,
},
{
path: "normal.ts",
target: "src/normal.ts",
type: "registry:lib" as const,
},
],
}
// The function should still return true - path validation is handled elsewhere
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
})

View File

@@ -242,3 +242,34 @@ 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)
}
/**
* Check if a registry item is universal (framework-agnostic).
* A universal registry item has all files with explicit targets.
* It can be installed without framework detection or components.json.
*/
export function isUniversalRegistryItem(
registryItem:
| Pick<z.infer<typeof registryItemSchema>, "files">
| null
| undefined
): boolean {
return (
!!registryItem?.files?.length &&
registryItem.files.every((file) => !!file.target)
)
}

View File

@@ -6,7 +6,10 @@ import {
registryResolveItemsTree,
resolveRegistryItems,
} from "@/src/registry/api"
import { registryItemSchema } from "@/src/registry/schema"
import {
registryItemFileSchema,
registryItemSchema,
} from "@/src/registry/schema"
import {
configSchema,
findCommonRoot,
@@ -17,6 +20,7 @@ import {
} from "@/src/utils/get-config"
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { isSafeTarget } from "@/src/utils/is-safe-target"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { updateCss } from "@/src/utils/updaters/update-css"
@@ -79,6 +83,14 @@ async function addProjectComponents(
registrySpinner?.fail()
return handleError(new Error("Failed to fetch components from registry."))
}
try {
validateFilesTarget(tree.files ?? [], config.resolvedPaths.cwd)
} catch (error) {
registrySpinner?.fail()
return handleError(error)
}
registrySpinner?.succeed()
const tailwindVersion = await getProjectTailwindVersionFromConfig(config)
@@ -147,6 +159,13 @@ async function addWorkspaceComponents(
const filesUpdated: string[] = []
const filesSkipped: string[] = []
const files = payload.flatMap((item) => item.files ?? [])
try {
validateFilesTarget(files, config.resolvedPaths.cwd)
} catch (error) {
return handleError(error)
}
const rootSpinner = spinner(`Installing components.`)?.start()
for (const component of payload) {
@@ -317,3 +336,20 @@ async function shouldOverwriteCssVars(
component.type === "registry:theme" || component.type === "registry:style"
)
}
function validateFilesTarget(
files: z.infer<typeof registryItemFileSchema>[],
cwd: string
) {
for (const file of files) {
if (!file?.target) {
continue
}
if (!isSafeTarget(file.target, cwd)) {
throw new Error(
`We found an unsafe file path "${file.target} in the registry item. Installation aborted.`
)
}
}
}

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

@@ -225,3 +225,64 @@ export async function getTargetStyleFromConfig(cwd: string, fallback: string) {
const projectInfo = await getProjectInfo(cwd)
return projectInfo?.tailwindVersion === "v4" ? "new-york-v4" : fallback
}
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
/**
* Creates a config object with sensible defaults.
* Useful for universal registry items that bypass framework detection.
*
* @param partial - Partial config values to override defaults
* @returns A complete Config object
*/
export function createConfig(partial?: DeepPartial<Config>): Config {
const defaultConfig: Config = {
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "",
tailwindCss: "",
utils: "",
components: "",
ui: "",
lib: "",
hooks: "",
},
style: "",
tailwind: {
config: "",
css: "",
baseColor: "",
cssVariables: false,
},
rsc: false,
tsx: true,
aliases: {
components: "",
utils: "",
},
}
// Deep merge the partial config with defaults
if (partial) {
return {
...defaultConfig,
...partial,
resolvedPaths: {
...defaultConfig.resolvedPaths,
...(partial.resolvedPaths || {}),
},
tailwind: {
...defaultConfig.tailwind,
...(partial.tailwind || {}),
},
aliases: {
...defaultConfig.aliases,
...(partial.aliases || {}),
},
}
}
return defaultConfig
}

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

@@ -0,0 +1,156 @@
import { describe, expect, test } from "vitest"
import { isSafeTarget } from "./is-safe-target"
describe("isSafeTarget", () => {
const cwd = "/foo/bar"
describe("should reject path traversal attempts", () => {
test.each([
{
description: "basic path traversal with ../",
target: "../../etc/passwd",
},
{
description: "nested path traversal",
target: "ui/../../../etc/hosts",
},
{
description: "path traversal with ~/../",
target: "~/../../../.ssh/authorized_keys",
},
{
description: "absolute paths outside project",
target: "/etc/passwd",
},
{
description: "paths that resolve outside project root",
target: "foo/bar/../../../../etc/passwd",
},
{
description: "URL-encoded path traversal",
target: "%2e%2e%2f%2e%2e%2fetc%2fpasswd",
},
{
description: "double URL-encoded sequences",
target: "%252e%252e%252fetc%252fpasswd",
},
{
description: "mixed encoded/plain traversal",
target: "..%2f..%2fetc%2fpasswd",
},
{
description: "null byte injection",
target: "valid/path\0../../etc/passwd",
},
{
description: "Windows-style path traversal",
target: "..\\..\\Windows\\System32\\config",
},
{
description: "Windows absolute paths",
target: "C:\\Windows\\System32\\drivers\\etc\\hosts",
},
{
description: "mixed separator traversal",
target: "foo\\..\\../etc/passwd",
},
{
description: "current directory reference attacks",
target: "foo/./././../../../etc/passwd",
},
{
description: "control characters in paths",
target: "foo/\x01\x02/../../etc/passwd",
},
{
description: "Unicode normalization attacks",
target: "foo/../\u2025/etc/passwd",
},
{
description:
"path traversal with square brackets outside [...] pattern",
target: "foo/[bar]/../../etc/passwd",
},
])("$description", ({ target }) => {
expect(isSafeTarget(target, cwd)).toBe(false)
})
})
describe("should accept safe paths", () => {
test.each([
{
description: "simple relative path",
target: "ui/button.tsx",
},
{
description: "nested relative path",
target: "components/ui/button.tsx",
},
{
description: "home directory expansion",
target: "~/foo.json",
},
{
description: "nested home directory path",
target: "~/components/button.tsx",
},
{
description: "dot in filename",
target: "components/.env.local",
},
{
description: "path with spaces",
target: "my components/button.tsx",
},
{
description: "path with special characters",
target: "components/@ui/button.tsx",
},
{
description: "framework routing with square brackets",
target: "pages/[id].tsx",
},
{
description: "catch-all routes with [...param]",
target: "server/api/auth/[...].ts",
},
{
description: "optional catch-all routes",
target: "pages/[[...slug]].tsx",
},
{
description: "dollar sign routes",
target: "routes/$userId.tsx",
},
{
description: "complex routing patterns",
target: "app/[locale]/[...segments]/page.tsx",
},
])("$description", ({ target }) => {
expect(isSafeTarget(target, cwd)).toBe(true)
})
})
describe("edge cases", () => {
test("should handle empty string", () => {
expect(isSafeTarget("", cwd)).toBe(true)
})
test("should handle single dot", () => {
expect(isSafeTarget(".", cwd)).toBe(true)
})
test("should reject malformed URL encoding", () => {
expect(isSafeTarget("%zz%ff%2e%2e%2f", cwd)).toBe(false)
})
test("should handle paths at project root", () => {
expect(isSafeTarget("/foo/bar/test.txt", cwd)).toBe(true)
})
test("should reject paths just outside project root", () => {
expect(isSafeTarget("/foo/test.txt", cwd)).toBe(false)
})
})
})

View File

@@ -0,0 +1,98 @@
import path from "path"
export function isSafeTarget(targetPath: string, cwd: string): boolean {
// Check for null bytes which can be used to bypass validations.
if (targetPath.includes("\0")) {
return false
}
// Decode URL-encoded sequences to catch encoded traversal attempts.
let decodedPath: string
try {
// Decode multiple times to catch double-encoded sequences.
decodedPath = targetPath
let prevPath = ""
while (decodedPath !== prevPath && decodedPath.includes("%")) {
prevPath = decodedPath
decodedPath = decodeURIComponent(decodedPath)
}
} catch {
// If decoding fails, treat as unsafe.
return false
}
// Normalize both paths to handle different path separators.
// Convert Windows backslashes to forward slashes for consistent handling.
const normalizedTarget = path.normalize(decodedPath.replace(/\\/g, "/"))
const normalizedRoot = path.normalize(cwd)
// Check for explicit path traversal sequences in both encoded and decoded forms.
// Allow [...] pattern which is common in framework routing (e.g., [...slug])
const hasPathTraversal = (path: string) => {
// Remove [...] patterns before checking for ..
const withoutBrackets = path.replace(/\[\.\.\..*?\]/g, "")
return withoutBrackets.includes("..")
}
if (
hasPathTraversal(normalizedTarget) ||
hasPathTraversal(decodedPath) ||
hasPathTraversal(targetPath)
) {
return false
}
// Check for current directory references that might be used in traversal.
// First, remove [...] patterns to avoid false positives
const cleanPath = (path: string) => path.replace(/\[\.\.\..*?\]/g, "")
const cleanTarget = cleanPath(targetPath)
const cleanDecoded = cleanPath(decodedPath)
const suspiciousPatterns = [
/\.\.[\/\\]/, // ../ or ..\
/[\/\\]\.\./, // /.. or \..
/\.\./, // .. anywhere
/\.\.%/, // URL encoded traversal
/\x00/, // null byte
/[\x01-\x1f]/, // control characters
]
if (
suspiciousPatterns.some(
(pattern) => pattern.test(cleanTarget) || pattern.test(cleanDecoded)
)
) {
return false
}
// Allow ~/ at the start (home directory expansion within project) but reject ~/../ patterns.
if (
(targetPath.includes("~") || decodedPath.includes("~")) &&
(targetPath.includes("../") || decodedPath.includes("../"))
) {
return false
}
// Check for Windows drive letters (even on non-Windows systems for safety).
const driveLetterRegex = /^[a-zA-Z]:[\/\\]/
if (driveLetterRegex.test(decodedPath)) {
// On Windows, check if it starts with the project root.
if (process.platform === "win32") {
return decodedPath.toLowerCase().startsWith(cwd.toLowerCase())
}
// On non-Windows systems, reject all Windows absolute paths.
return false
}
// If it's an absolute path, ensure it's within the project root.
if (path.isAbsolute(normalizedTarget)) {
return normalizedTarget.startsWith(normalizedRoot + path.sep)
}
// For relative paths, resolve and check if within project bounds.
const resolvedPath = path.resolve(normalizedRoot, normalizedTarget)
return (
resolvedPath.startsWith(normalizedRoot + path.sep) ||
resolvedPath === normalizedRoot
)
}

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

@@ -51,7 +51,9 @@ export async function updateFiles(
const [projectInfo, baseColor] = await Promise.all([
getProjectInfo(config.resolvedPaths.cwd),
getRegistryBaseColor(config.tailwind.baseColor),
config.tailwind.baseColor
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
let filesCreated: string[] = []

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

@@ -1,7 +1,11 @@
import path from "path"
import { expect, test } from "vitest"
import { describe, expect, test } from "vitest"
import { getConfig, getRawConfig } from "../../src/utils/get-config"
import {
createConfig,
getConfig,
getRawConfig,
} from "../../src/utils/get-config"
test("get raw config", async () => {
expect(
@@ -183,3 +187,129 @@ test("get config", async () => {
},
})
})
describe("createConfig", () => {
test("creates default config when called without arguments", () => {
const config = createConfig()
expect(config).toMatchObject({
resolvedPaths: {
cwd: expect.any(String),
tailwindConfig: "",
tailwindCss: "",
utils: "",
components: "",
ui: "",
lib: "",
hooks: "",
},
style: "",
tailwind: {
config: "",
css: "",
baseColor: "",
cssVariables: false,
},
rsc: false,
tsx: true,
aliases: {
components: "",
utils: "",
},
})
})
test("overrides cwd in resolvedPaths", () => {
const customCwd = "/custom/path"
const config = createConfig({
resolvedPaths: {
cwd: customCwd,
},
})
expect(config.resolvedPaths.cwd).toBe(customCwd)
expect(config.resolvedPaths.components).toBe("")
expect(config.resolvedPaths.utils).toBe("")
})
test("overrides style", () => {
const config = createConfig({
style: "new-york",
})
expect(config.style).toBe("new-york")
})
test("overrides tailwind settings", () => {
const config = createConfig({
tailwind: {
baseColor: "slate",
cssVariables: true,
},
})
expect(config.tailwind.baseColor).toBe("slate")
expect(config.tailwind.cssVariables).toBe(true)
expect(config.tailwind.config).toBe("")
expect(config.tailwind.css).toBe("")
})
test("overrides boolean flags", () => {
const config = createConfig({
rsc: true,
tsx: false,
})
expect(config.rsc).toBe(true)
expect(config.tsx).toBe(false)
})
test("overrides aliases", () => {
const config = createConfig({
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
})
expect(config.aliases.components).toBe("@/components")
expect(config.aliases.utils).toBe("@/lib/utils")
})
test("handles complex partial overrides", () => {
const config = createConfig({
style: "default",
resolvedPaths: {
cwd: "/my/project",
components: "/my/project/src/components",
},
tailwind: {
baseColor: "zinc",
prefix: "tw-",
},
aliases: {
ui: "@/components/ui",
},
})
expect(config.style).toBe("default")
expect(config.resolvedPaths.cwd).toBe("/my/project")
expect(config.resolvedPaths.components).toBe("/my/project/src/components")
expect(config.resolvedPaths.utils).toBe("")
expect(config.tailwind.baseColor).toBe("zinc")
expect(config.tailwind.prefix).toBe("tw-")
expect(config.tailwind.css).toBe("")
expect(config.aliases.ui).toBe("@/components/ui")
expect(config.aliases.components).toBe("")
})
test("returns new object instances", () => {
const config1 = createConfig()
const config2 = createConfig()
expect(config1).not.toBe(config2)
expect(config1.resolvedPaths).not.toBe(config2.resolvedPaths)
expect(config1.tailwind).not.toBe(config2.tailwind)
expect(config1.aliases).not.toBe(config2.aliases)
})
})

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

113
pnpm-lock.yaml generated
View File

@@ -132,6 +132,9 @@ importers:
'@hookform/resolvers':
specifier: ^3.10.0
version: 3.10.0(react-hook-form@7.54.2(react@19.1.0))
'@orama/orama':
specifier: ^3.1.11
version: 3.1.11
'@radix-ui/react-accessible-icon':
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -294,6 +297,9 @@ importers:
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
nuqs:
specifier: ^2.4.3
version: 2.4.3(next@15.3.1(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
postcss:
specifier: ^8.5.1
version: 8.5.1
@@ -322,7 +328,7 @@ importers:
specifier: ^6.0.1
version: 6.0.1
shadcn:
specifier: 2.6.2
specifier: 2.9.0
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1
@@ -602,7 +608,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.2
specifier: 2.9.0
version: link:../../packages/shadcn
sharp:
specifier: ^0.32.6
@@ -696,16 +702,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':
@@ -2639,8 +2635,8 @@ packages:
resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==}
engines: {node: '>=14'}
'@orama/orama@3.1.7':
resolution: {integrity: sha512-6yB0117ZjsgNevZw3LP+bkrZa9mU/POPVaXgzMPOBbBc35w2P3R+1vMMhEfC06kYCpd5bf0jodBaTkYQW5TVeQ==}
'@orama/orama@3.1.11':
resolution: {integrity: sha512-Szki0cgFiXE5F9RLx2lUyEtJllnuCSQ4B8RLDwIjXkVit6qZjoDAxH+xhJs29MjKLDz0tbPLdKFa6QrQ/qoGGA==}
engines: {node: '>= 20.0.0'}
'@oxc-transform/binding-darwin-arm64@0.53.0':
@@ -4779,10 +4775,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==}
@@ -7563,6 +7555,24 @@ packages:
resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
nuqs@2.4.3:
resolution: {integrity: sha512-BgtlYpvRwLYiJuWzxt34q2bXu/AIS66sLU1QePIMr2LWkb+XH0vKXdbLSgn9t6p7QKzwI7f38rX3Wl9llTXQ8Q==}
peerDependencies:
'@remix-run/react': '>=2'
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^6 || ^7
react-router-dom: ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
optional: true
next:
optional: true
react-router:
optional: true
react-router-dom:
optional: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -10074,7 +10084,7 @@ snapshots:
'@types/node': 20.5.1
chalk: 4.1.2
cosmiconfig: 8.3.6(typescript@5.7.3)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.7.3))(typescript@5.7.3)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.17.16)(typescript@5.7.3))(typescript@5.7.3)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@@ -11451,7 +11461,7 @@ snapshots:
'@opentelemetry/semantic-conventions@1.28.0': {}
'@orama/orama@3.1.7': {}
'@orama/orama@3.1.11': {}
'@oxc-transform/binding-darwin-arm64@0.53.0':
optional: true
@@ -14349,8 +14359,6 @@ snapshots:
chalk@5.2.0: {}
chalk@5.4.1: {}
change-case@5.4.4: {}
character-entities-html4@2.1.0: {}
@@ -14565,7 +14573,7 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.7.3))(typescript@5.7.3):
cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@20.17.16)(typescript@5.7.3))(typescript@5.7.3):
dependencies:
'@types/node': 20.5.1
cosmiconfig: 8.3.6(typescript@5.7.3)
@@ -15988,7 +15996,7 @@ snapshots:
fumadocs-core@15.4.2(@types/react@19.1.2)(next@15.3.1(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@formatjs/intl-localematcher': 0.6.1
'@orama/orama': 3.1.7
'@orama/orama': 3.1.11
'@shikijs/rehype': 3.4.2
'@shikijs/transformers': 3.4.2
github-slugger: 2.0.0
@@ -18152,6 +18160,13 @@ snapshots:
npm-to-yarn@3.0.1: {}
nuqs@2.4.3(next@15.3.1(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0):
dependencies:
mitt: 3.0.1
react: 19.1.0
optionalDependencies:
next: 15.3.1(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
object-assign@4.1.1: {}
object-hash@3.0.0: {}
@@ -18447,14 +18462,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 +20107,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 +20155,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"
}

View File

@@ -55,6 +55,10 @@
"registry:build": {
"cache": false,
"outputs": []
},
"build:external": {
"cache": false,
"outputs": [".data/external-registries.json", ".data/external-registries-index.json"]
}
}
}