Compare commits

..

27 Commits

Author SHA1 Message Date
shadcn
0940c6aec7 chore: update deps 2025-07-30 12:11:45 +04:00
github-actions[bot]
e244952500 chore(release): version packages (#7909)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-30 12:10:45 +04:00
shadcn
0e3d6b24d3 test: fix flaky remote registry test (#7910)
* test: fix flaky remote registry test

* fix

* fix: test

* fix

* fix

* fix

* fix

* fix

* tests: cleanup
2025-07-30 12:06:13 +04:00
shadcn
cef5af9ed3 ci: bump version for changeset action 2025-07-29 17:08:48 +04:00
shadcn
6deb0fdbb6 chore: remove tests form changesets 2025-07-29 17:01:45 +04:00
shadcn
e9ae79f874 ci: fix 2025-07-29 16:57:16 +04:00
shadcn
d891132f2a test: remove init tests (#7908)
* test(shadcn): remove init test

* chore: changeset
2025-07-29 16:45:52 +04:00
shadcn
873f7f2773 feat: add tests package (#7907)
* feat: add tests package

* fix

* fix

* debug

* debug

* debug

* fix

* debug

* fix: no concurrent

* fix

* test: add vite-app tests

* test: add tests
2025-07-29 16:31:10 +04:00
shadcn
e6778dee87 feat(shadcn): add envVars to schema (#7902)
* feat(shadcn): add envVars to schema

* fix(shadcn): tests

* chore: changeset
2025-07-28 12:14:46 +04:00
shadcn
97a8de1c1b feat: update handling of env files in registry (#7896)
* feat: handle env update

* tests(shadcn): add tests for env helpers

* test(shadcn): update files test

* feat(shadcn): implement file alternatives

* test(shadcn): fix alternative handling

* fix(shadcn): env var logging

* test(shadcn): add tests for multi line env

* chore: changeset

* ci: update
2025-07-27 12:28:39 +04:00
shadcn
19d7fbb731 Use v4 blocks for Open in v0 (#7898)
* feat(www): use v4 blocks for v0

* fix: defaultIndex
2025-07-27 11:34:07 +04:00
shadcn
a9ab05ad83 Merge branch 'main' of github.com:shadcn-ui/ui 2025-07-23 11:57:19 +04:00
shadcn
6ac114ae68 feat: update hero 2025-07-23 11:57:05 +04:00
Mohit Khatri
d5770e4350 fix: resolve table overflow styling issues (#7874) 2025-07-23 11:32:05 +04:00
shadcn
4730276256 fix: spacing 2025-07-23 11:30:23 +04:00
shadcn
4e04567b07 deps: lock file 2025-07-23 10:58:45 +04:00
github-actions[bot]
6f63b04d28 chore(release): version packages (#7866)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-07-23 10:57:12 +04:00
shadcn
e38228b574 feat: implement open in (#7875)
* feat: implement open in

* fix: display
2025-07-23 10:30:32 +04:00
shadcn
8807103586 fix: index 2025-07-22 21:18:33 +04:00
shadcn
3424ab709e feat: add docs copy page (#7872) 2025-07-22 21:06:27 +04:00
shadcn
4a86a55cac feat(www): implement llm routes (#7868) 2025-07-22 19:59:38 +04:00
shadcn
2926574d0e fix(shadcn): universal item files type (#7867)
* fix(shadcn): universal item files type

* chore: changeset

* fix: style
2025-07-22 14:58:54 +04:00
shadcn
20e913d8e1 fix: handling of themes in registry (#7837) 2025-07-22 11:13:37 +04:00
github-actions[bot]
3433aaffaa chore(release): version packages (#7834)
* 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-16 18:26:52 +04:00
shadcn
d9cdc3f7ae Revert "fix: handling of shouldOverwriteCssVars" (#7833)
* Revert "fix: handling of shouldOverwriteCssVars (#7829)"

This reverts commit ed5237c231.

* fix: revert
2025-07-16 18:22:40 +04:00
github-actions[bot]
e75e7b3866 chore(release): version packages (#7830)
* 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-07-16 17:52:23 +04:00
shadcn
ed5237c231 fix: handling of shouldOverwriteCssVars (#7829)
* fix(shadcn): handling of shouldOverwriteCssVars

* chore: changeset
2025-07-16 17:44:25 +04:00
90 changed files with 3387 additions and 1767 deletions

View File

@@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["www", "v4"]
"ignore": ["www", "v4", "tests"]
}

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run typecheck:*)"
],
"deny": []
}
}

View File

@@ -41,7 +41,7 @@ jobs:
- name: Create Version PR or Publish to NPM
id: changesets
uses: changesets/action@v1.4.1
uses: changesets/action@v1
with:
commit: "chore(release): version packages"
title: "chore(release): version packages"

View File

@@ -8,6 +8,9 @@ jobs:
test:
runs-on: ubuntu-latest
name: pnpm test
env:
NEXT_PUBLIC_APP_URL: http://localhost:4000
NEXT_PUBLIC_V0_URL: https://v0.dev
steps:
- uses: actions/checkout@v3
with:
@@ -39,4 +42,7 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpm build --filter=shadcn
- run: pnpm test

3
apps/v4/.gitignore vendored
View File

@@ -46,6 +46,3 @@ next-env.d.ts
.contentlayer
.content-collections
.source
# Generated data
.data/

View File

@@ -15,9 +15,9 @@ import { PageNav } from "@/components/page-nav"
import { ThemeSelector } from "@/components/theme-selector"
import { Button } from "@/registry/new-york-v4/ui/button"
const title = "Build your Component Library"
const title = "The Foundation for your Design System"
const description =
"A set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. Open Source. Open Code."
"A set of beautifully designed components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code."
export const dynamic = "force-static"
export const revalidate = false
@@ -51,14 +51,14 @@ export default function IndexPage() {
<div className="flex flex-1 flex-col">
<PageHeader>
<Announcement />
<PageHeaderHeading>{title}</PageHeaderHeading>
<PageHeaderHeading className="max-w-4xl">{title}</PageHeaderHeading>
<PageHeaderDescription>{description}</PageHeaderDescription>
<PageActions>
<Button asChild size="sm">
<Link href="/docs/installation">Get Started</Link>
</Button>
<Button asChild size="sm" variant="ghost">
<Link href="/blocks">Browse Blocks</Link>
<Link href="/docs/components">View Components</Link>
</Button>
</PageActions>
</PageHeader>

View File

@@ -10,6 +10,7 @@ import { findNeighbour } from "fumadocs-core/server"
import { source } from "@/lib/source"
import { absoluteUrl } from "@/lib/utils"
import { DocsCopyPage } from "@/components/docs-copy-page"
import { DocsTableOfContents } from "@/components/docs-toc"
import { OpenInV0Cta } from "@/components/open-in-v0-cta"
import { Badge } from "@/registry/new-york-v4/ui/badge"
@@ -102,12 +103,17 @@ export default async function Page(props: {
<h1 className="scroll-m-20 text-4xl font-semibold tracking-tight sm:text-3xl xl:text-4xl">
{doc.title}
</h1>
<div className="flex items-center gap-2 pt-1.5">
<div className="docs-nav bg-background/80 border-border/50 fixed inset-x-0 bottom-0 isolate z-50 flex items-center gap-2 border-t px-6 py-4 backdrop-blur-sm sm:static sm:z-0 sm:border-t-0 sm:bg-transparent sm:px-0 sm:pt-1.5 sm:backdrop-blur-none">
<DocsCopyPage
// @ts-expect-error - revisit fumadocs types.
page={doc.content}
url={absoluteUrl(page.url)}
/>
{neighbours.previous && (
<Button
variant="secondary"
size="icon"
className="extend-touch-target size-8 shadow-none md:size-7"
className="extend-touch-target ml-auto size-8 shadow-none md:size-7"
asChild
>
<Link href={neighbours.previous.url}>
@@ -160,7 +166,7 @@ export default async function Page(props: {
<MDX components={mdxComponents} />
</div>
</div>
<div className="mx-auto flex h-16 w-full max-w-2xl items-center gap-2 px-4 md:px-0">
<div className="mx-auto hidden h-16 w-full max-w-2xl items-center gap-2 px-4 sm:flex md:px-0">
{neighbours.previous && (
<Button
variant="secondary"

View File

@@ -75,7 +75,7 @@ export function DataTable<TData, TValue>({
return (
<div className="flex flex-col gap-4">
<DataTableToolbar table={table} />
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -0,0 +1,29 @@
import { notFound } from "next/navigation"
import { NextResponse, type NextRequest } from "next/server"
import { source } from "@/lib/source"
export const revalidate = false
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const slug = (await params).slug
const page = source.getPage(slug)
if (!page) {
notFound()
}
// @ts-expect-error - revisit fumadocs types.
return new NextResponse(page.data.content, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
},
})
}
export function generateStaticParams() {
return source.generateParams()
}

View File

@@ -1,150 +0,0 @@
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,5 +1,4 @@
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"
@@ -89,18 +88,16 @@ export default function RootLayout({
fontVariables
)}
>
<NuqsAdapter>
<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>
</body>
</html>
)

View File

@@ -209,7 +209,7 @@ export function CardsPayments() {
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -1,236 +0,0 @@
"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

@@ -1,33 +1,156 @@
"use client"
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { IconCheck, IconChevronDown, IconCopy } from "@tabler/icons-react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/registry/new-york-v4/ui/dropdown-menu"
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/registry/new-york-v4/ui/popover"
import { Separator } from "@/registry/new-york-v4/ui/separator"
export function DocsCopyPage({ page }: { page: string }) {
function getPromptUrl(baseURL: string, url: string) {
return `${baseURL}?q=${encodeURIComponent(
`Im looking at this shadcn/ui documentation: ${url}.
Help me understand how to use it. Be ready to explain concepts, give examples, or help debug based on it.
`
)}`
}
const menuItems = {
markdown: (url: string) => (
<a href={`${url}.mdx`} target="_blank" rel="noopener noreferrer">
<svg strokeLinejoin="round" viewBox="0 0 22 16">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.5 2.25H2.5C1.80964 2.25 1.25 2.80964 1.25 3.5V12.5C1.25 13.1904 1.80964 13.75 2.5 13.75H19.5C20.1904 13.75 20.75 13.1904 20.75 12.5V3.5C20.75 2.80964 20.1904 2.25 19.5 2.25ZM2.5 1C1.11929 1 0 2.11929 0 3.5V12.5C0 13.8807 1.11929 15 2.5 15H19.5C20.8807 15 22 13.8807 22 12.5V3.5C22 2.11929 20.8807 1 19.5 1H2.5ZM3 4.5H4H4.25H4.6899L4.98715 4.82428L7 7.02011L9.01285 4.82428L9.3101 4.5H9.75H10H11V5.5V11.5H9V7.79807L7.73715 9.17572L7 9.97989L6.26285 9.17572L5 7.79807V11.5H3V5.5V4.5ZM15 8V4.5H17V8H19.5L17 10.5L16 11.5L15 10.5L12.5 8H15Z"
fill="currentColor"
/>
</svg>
View as Markdown
</a>
),
v0: (url: string) => (
<a
href={getPromptUrl("https://v0.dev", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 147 70"
className="size-4.5 -translate-x-px"
>
<path d="M56 50.203V14h14v46.156C70 65.593 65.593 70 60.156 70c-2.596 0-5.158-1-7-2.843L0 14h19.797L56 50.203ZM147 56h-14V23.953L100.953 56H133v14H96.687C85.814 70 77 61.186 77 50.312V14h14v32.156L123.156 14H91V0h36.312C138.186 0 147 8.814 147 19.688V56Z" />
</svg>
<span className="-translate-x-[2px]">Open in v0</span>
</a>
),
chatgpt: (url: string) => (
<a
href={getPromptUrl("https://chatgpt.com", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5Z"
fill="currentColor"
/>
</svg>
Open in ChatGPT
</a>
),
claude: (url: string) => (
<a
href={getPromptUrl("https://claude.ai/new", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="m4.714 15.956 4.718-2.648.079-.23-.08-.128h-.23l-.79-.048-2.695-.073-2.337-.097-2.265-.122-.57-.121-.535-.704.055-.353.48-.321.685.06 1.518.104 2.277.157 1.651.098 2.447.255h.389l.054-.158-.133-.097-.103-.098-2.356-1.596-2.55-1.688-1.336-.972-.722-.491L2 6.223l-.158-1.008.655-.722.88.06.225.061.893.686 1.906 1.476 2.49 1.833.364.304.146-.104.018-.072-.164-.274-1.354-2.446-1.445-2.49-.644-1.032-.17-.619a2.972 2.972 0 0 1-.103-.729L6.287.133 6.7 0l.995.134.42.364.619 1.415L9.735 4.14l1.555 3.03.455.898.243.832.09.255h.159V9.01l.127-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.583.28.48.685-.067.444-.286 1.851-.558 2.903-.365 1.942h.213l.243-.242.983-1.306 1.652-2.064.728-.82.85-.904.547-.431h1.032l.759 1.129-.34 1.166-1.063 1.347-.88 1.142-1.263 1.7-.79 1.36.074.11.188-.02 2.853-.606 1.542-.28 1.84-.315.832.388.09.395-.327.807-1.967.486-2.307.462-3.436.813-.043.03.049.061 1.548.146.662.036h1.62l3.018.225.79.522.473.638-.08.485-1.213.62-1.64-.389-3.825-.91-1.31-.329h-.183v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.321.455-.34-.049-2.204-1.657-.85-.747-1.925-1.62h-.127v.17l.443.649 2.343 3.521.122 1.08-.17.353-.607.213-.668-.122-1.372-1.924-1.415-2.168-1.141-1.943-.14.08-.674 7.254-.316.37-.728.28-.607-.461-.322-.747.322-1.476.388-1.924.316-1.53.285-1.9.17-.632-.012-.042-.14.018-1.432 1.967-2.18 2.945-1.724 1.845-.413.164-.716-.37.066-.662.401-.589 2.386-3.036 1.439-1.882.929-1.086-.006-.158h-.055L4.138 18.56l-1.13.146-.485-.456.06-.746.231-.243 1.907-1.312Z"
fill="currentColor"
/>
</svg>
Open in Claude
</a>
),
}
export function DocsCopyPage({ page, url }: { page: string; url: string }) {
const { copyToClipboard, isCopied } = useCopyToClipboard()
const trigger = (
<Button
variant="secondary"
size="sm"
className="peer -ml-0.5 size-8 shadow-none md:size-7 md:text-[0.8rem]"
>
<IconChevronDown className="rotate-180 sm:rotate-0" />
</Button>
)
return (
<Tooltip>
<TooltipTrigger asChild>
<Popover>
<div className="bg-secondary group/buttons relative flex rounded-lg *:[[data-slot=button]]:focus-visible:relative *:[[data-slot=button]]:focus-visible:z-10">
<PopoverAnchor />
<Button
variant="outline"
variant="secondary"
size="sm"
className="h-8 pl-1.5 md:h-7 [&>svg]:size-3.5"
className="h-8 shadow-none md:h-7 md:text-[0.8rem]"
onClick={() => copyToClipboard(page)}
>
{isCopied ? <IconCheck /> : <IconCopy />} Copy Page
{isCopied ? <IconCheck /> : <IconCopy />}
Copy Page
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy as Markdown</p>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild className="hidden sm:flex">
{trigger}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="shadow-none">
{Object.entries(menuItems).map(([key, value]) => (
<DropdownMenuItem key={key} asChild>
{value(url)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator
orientation="vertical"
className="!bg-foreground/10 absolute top-0 right-8 z-0 !h-8 peer-focus-visible:opacity-0 sm:right-7 sm:!h-7"
/>
<PopoverTrigger asChild className="flex sm:hidden">
{trigger}
</PopoverTrigger>
<PopoverContent
className="bg-background/70 dark:bg-background/60 w-52 !origin-center rounded-lg p-1 shadow-sm backdrop-blur-sm"
align="start"
>
{Object.entries(menuItems).map(([key, value]) => (
<Button
variant="ghost"
size="lg"
asChild
key={key}
className="*:[svg]:text-muted-foreground w-full justify-start text-base font-normal"
>
{value(url)}
</Button>
))}
</PopoverContent>
</div>
</Popover>
)
}

View File

@@ -3,7 +3,7 @@ import { Icons } from "@/components/icons"
import { Button } from "@/registry/new-york-v4/ui/button"
// v0 uses the default style.
const V0_STYLE = "default"
const V0_STYLE = "new-york-v4"
export function OpenInV0Button({
name,

View File

@@ -2,10 +2,10 @@ import { siteConfig } from "@/lib/config"
export function SiteFooter() {
return (
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent dark:bg-transparent">
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent group-has-[.docs-nav]/body:pb-20 group-has-[.docs-nav]/body:sm:pb-0 dark:bg-transparent">
<div className="container-wrapper px-4 xl:px-6">
<div className="flex h-(--footer-height) items-center justify-between">
<div className="text-muted-foreground w-full text-center text-xs leading-loose sm:text-sm">
<div className="text-muted-foreground w-full px-1 text-center text-xs leading-loose sm:text-sm">
Built by{" "}
<a
href={siteConfig.links.twitter}

View File

@@ -1,12 +0,0 @@
---
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

@@ -185,7 +185,7 @@ export function DataTable<TData, TValue>({
})
return (
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -425,7 +425,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
{ // .... }
</Table>
@@ -499,7 +499,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>{ ... }</Table>
</div>
</div>
@@ -602,7 +602,7 @@ export function DataTable<TData, TValue>({
className="max-w-sm"
/>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>{ ... }</Table>
</div>
</div>
@@ -715,7 +715,7 @@ export function DataTable<TData, TValue>({
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>{ ... }</Table>
</div>
</div>
@@ -805,7 +805,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table />
</div>
</div>

View File

@@ -3,9 +3,4 @@ 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

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

View File

@@ -3,7 +3,7 @@ export const siteConfig = {
url: "https://ui.shadcn.com",
ogImage: "https://ui.shadcn.com/og.jpg",
description:
"A set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. Open Source. Open Code.",
"A set of beautifully designed components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code.",
links: {
twitter: "https://twitter.com/shadcn",
github: "https://github.com/shadcn-ui/ui",
@@ -33,10 +33,6 @@ export const siteConfig = {
href: "/colors",
label: "Colors",
},
{
href: "/docs/components/community",
label: "Community",
},
],
}

View File

@@ -68,10 +68,13 @@ const nextConfig = {
destination: "/view/:name",
permanent: true,
},
]
},
rewrites() {
return [
{
source: "/community",
destination: "/docs/components/community",
permanent: false,
source: "/docs/:path*.mdx",
destination: "/llm/:path*",
},
]
},

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev --turbopack --port 4000",
"build": "pnpm --filter=shadcn build && pnpm build:external && next build",
"build": "pnpm --filter=shadcn build && next build",
"start": "next start --port 4000",
"lint": "next lint",
"lint:fix": "next lint --fix",
@@ -14,7 +14,6 @@
"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": {
@@ -24,7 +23,6 @@
"@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",
@@ -56,7 +54,7 @@
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.7",
"@tabler/icons-react": "^3.31.0",
"@tailwindcss/postcss": "^4.0.1",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-table": "^8.9.1",
"@vercel/analytics": "^1.4.1",
"change-case": "^5.4.4",
@@ -79,7 +77,6 @@
"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",
@@ -89,7 +86,7 @@
"recharts": "2.15.1",
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"shadcn": "2.9.0",
"shadcn": "2.10.0",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.1",
@@ -111,7 +108,7 @@
"eslint-config-next": "15.3.1",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.1.7",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.2.4",
"typescript": "^5",
"unist-builder": "3.0.0",

File diff suppressed because one or more lines are too long

View File

@@ -191,6 +191,13 @@
]
}
},
"envVars": {
"type": "object",
"description": "Environment variables required by the registry item. Key-value pairs that will be added to the project's .env file.",
"additionalProperties": {
"type": "string"
}
},
"meta": {
"type": "object",
"description": "Additional metadata for the registry item. This is an object with any key value pairs.",

View File

@@ -257,7 +257,6 @@ export function ChartAreaInteractive() {
/>
<ChartTooltip
cursor={false}
defaultIndex={isMobile ? -1 : 10}
content={
<ChartTooltipContent
labelFormatter={(value) => {

View File

@@ -233,7 +233,7 @@ export default function DataTableDemo() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

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

View File

@@ -1,171 +0,0 @@
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

@@ -70,7 +70,7 @@ export function DataTable<TData, TValue>({
return (
<div className="space-y-4">
<DataTableToolbar table={table} />
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -239,7 +239,7 @@ export function CardsDataTable() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -185,7 +185,7 @@ export function DataTable<TData, TValue>({
})
return (
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -425,7 +425,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
{ // .... }
</Table>
@@ -499,7 +499,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>{ ... }</Table>
</div>
</div>
@@ -602,7 +602,7 @@ export function DataTable<TData, TValue>({
className="max-w-sm"
/>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>{ ... }</Table>
</div>
</div>
@@ -715,7 +715,7 @@ export function DataTable<TData, TValue>({
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>{ ... }</Table>
</div>
</div>
@@ -805,7 +805,7 @@ export function DataTable<TData, TValue>({
return (
<div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table />
</div>
</div>

View File

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

View File

@@ -233,7 +233,7 @@ export default function DataTableDemo() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -233,7 +233,7 @@ export default function DataTableDemo() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -35,7 +35,6 @@
"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",
@@ -47,7 +46,8 @@
"release": "changeset version",
"pub:beta": "cd packages/shadcn && pnpm pub:beta",
"pub:release": "cd packages/shadcn && pnpm pub:release",
"test": "turbo run test --filter=!shadcn-ui --force"
"test:dev": "turbo run test --filter=!shadcn-ui --force",
"test": "start-server-and-test v4:dev http://localhost:4000 test:dev"
},
"packageManager": "pnpm@9.0.6",
"dependencies": {
@@ -86,6 +86,7 @@
"@types/node": "^20.11.27",
"@types/react": "^18.2.65",
"@types/react-dom": "^18.2.22",
"start-server-and-test": "^2.0.12",
"typescript": "^5.5.3"
}
}

View File

@@ -1,5 +1,35 @@
# @shadcn/ui
## 2.10.0
### Minor Changes
- [#7902](https://github.com/shadcn-ui/ui/pull/7902) [`e6778dee87de1a183843f233b3f27fbfb1a700ec`](https://github.com/shadcn-ui/ui/commit/e6778dee87de1a183843f233b3f27fbfb1a700ec) Thanks [@shadcn](https://github.com/shadcn)! - add support for envVars in schema
- [#7896](https://github.com/shadcn-ui/ui/pull/7896) [`97a8de1c1b2ae590cc9dbe17970a882990c35a59`](https://github.com/shadcn-ui/ui/commit/97a8de1c1b2ae590cc9dbe17970a882990c35a59) Thanks [@shadcn](https://github.com/shadcn)! - add support for env vars in registry
### Patch Changes
- [#7908](https://github.com/shadcn-ui/ui/pull/7908) [`d891132f2a0121e12c92839e19f5d90252f9a640`](https://github.com/shadcn-ui/ui/commit/d891132f2a0121e12c92839e19f5d90252f9a640) Thanks [@shadcn](https://github.com/shadcn)! - remove init tests
## 2.9.3
### Patch Changes
- [#7837](https://github.com/shadcn-ui/ui/pull/7837) [`20e913d8e1df1acddc7bd4b8328088a25869ba7c`](https://github.com/shadcn-ui/ui/commit/20e913d8e1df1acddc7bd4b8328088a25869ba7c) Thanks [@shadcn](https://github.com/shadcn)! - fix handling of themes
## 2.9.2
### Patch Changes
- [#7833](https://github.com/shadcn-ui/ui/pull/7833) [`d9cdc3f7ae69e571de7dc116effc381ad76685c3`](https://github.com/shadcn-ui/ui/commit/d9cdc3f7ae69e571de7dc116effc381ad76685c3) Thanks [@shadcn](https://github.com/shadcn)! - Revert "fix: handling of shouldOverwriteCssVars"
## 2.9.1
### Patch Changes
- [#7829](https://github.com/shadcn-ui/ui/pull/7829) [`ed5237c231f3b70107131bd7ba517e73b8c9014d`](https://github.com/shadcn-ui/ui/commit/ed5237c231f3b70107131bd7ba517e73b8c9014d) Thanks [@shadcn](https://github.com/shadcn)! - fix handling of shouldOverwriteCssVars
## 2.9.0
### Minor Changes

View File

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

View File

@@ -476,7 +476,12 @@ export async function registryResolveItemsTree(
}
})
return registryResolvedItemsTreeSchema.parse({
let envVars = {}
payload.forEach((item) => {
envVars = deepmerge(envVars, item.envVars ?? {})
})
const parsed = registryResolvedItemsTreeSchema.parse({
dependencies: deepmerge.all(
payload.map((item) => item.dependencies ?? [])
),
@@ -489,6 +494,12 @@ export async function registryResolveItemsTree(
css,
docs,
})
if (Object.keys(envVars).length > 0) {
parsed.envVars = envVars
}
return parsed
} catch (error) {
handleError(error)
return null

View File

@@ -65,6 +65,8 @@ export const registryItemCssSchema = z.record(
)
)
export const registryItemEnvVarsSchema = z.record(z.string(), z.string())
export const registryItemSchema = z.object({
$schema: z.string().optional(),
extends: z.string().optional(),
@@ -80,6 +82,7 @@ export const registryItemSchema = z.object({
tailwind: registryItemTailwindSchema.optional(),
cssVars: registryItemCssVarsSchema.optional(),
css: registryItemCssSchema.optional(),
envVars: registryItemEnvVarsSchema.optional(),
meta: z.record(z.string(), z.any()).optional(),
docs: z.string().optional(),
categories: z.array(z.string()).optional(),
@@ -127,5 +130,6 @@ export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
tailwind: true,
cssVars: true,
css: true,
envVars: true,
docs: true,
})

View File

@@ -137,18 +137,32 @@ describe("isLocalFile", () => {
})
describe("isUniversalRegistryItem", () => {
it("should return true when all files have targets", () => {
it("should return true when all files have targets with registry:file type", () => {
const registryItem = {
files: [
{
path: "file1.ts",
target: "src/file1.ts",
type: "registry:lib" as const,
type: "registry:file" as const,
},
{
path: "file2.ts",
target: "src/utils/file2.ts",
type: "registry:lib" as const,
type: "registry:file" as const,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
it("should return true for any registry item type if all files are registry:file with targets", () => {
const registryItem = {
type: "registry:ui" as const,
files: [
{
path: "cursor-rules.txt",
target: "~/.cursor/rules/react.txt",
type: "registry:file" as const,
},
],
}
@@ -161,9 +175,27 @@ describe("isUniversalRegistryItem", () => {
{
path: "file1.ts",
target: "src/file1.ts",
type: "registry:lib" as const,
type: "registry:file" as const,
},
{ path: "file2.ts", target: "", type: "registry:file" as const },
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when files have non-registry:file type", () => {
const registryItem = {
files: [
{
path: "file1.ts",
target: "src/file1.ts",
type: "registry:file" as const,
},
{
path: "file2.ts",
target: "src/lib/file2.ts",
type: "registry:lib" as const, // Not registry:file
},
{ path: "file2.ts", target: "", type: "registry:lib" as const },
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
@@ -172,8 +204,8 @@ describe("isUniversalRegistryItem", () => {
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 },
{ path: "file1.ts", target: "", type: "registry:file" as const },
{ path: "file2.ts", target: "", type: "registry:file" as const },
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
@@ -205,7 +237,7 @@ describe("isUniversalRegistryItem", () => {
{
path: "file1.ts",
target: null as any,
type: "registry:lib" as const,
type: "registry:file" as const,
},
],
}
@@ -214,60 +246,96 @@ describe("isUniversalRegistryItem", () => {
it("should return false when target is undefined", () => {
const registryItem = {
files: [{ path: "file1.ts", type: "registry:lib" as const }],
files: [
{
path: "file1.ts",
type: "registry:file" as const,
target: undefined as any,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should handle mixed file types correctly", () => {
it("should return false when files have registry:component type even with targets", () => {
const registryItem = {
files: [
{
path: "component.tsx",
target: "components/ui/component.tsx",
type: "registry:ui" as const,
type: "registry:component" as const,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when files have registry:hook type even with targets", () => {
const registryItem = {
files: [
{
path: "use-hook.ts",
target: "hooks/use-hook.ts",
type: "registry:hook" as const,
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return false when files have registry:lib type even with targets", () => {
const registryItem = {
files: [
{
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)
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
it("should return true when all targets are non-empty strings", () => {
it("should return true when all targets are non-empty strings for registry:file", () => {
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
{ path: "file1.ts", target: " ", type: "registry:file" as const }, // whitespace is truthy
{ path: "file2.ts", target: "0", type: "registry:file" as const }, // "0" is truthy
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
it("should handle real-world example with path traversal attempts", () => {
it("should handle real-world example with path traversal attempts for registry:file", () => {
const registryItem = {
files: [
{
path: "malicious.ts",
target: "../../../etc/passwd",
type: "registry:lib" as const,
type: "registry:file" as const,
},
{
path: "normal.ts",
target: "src/normal.ts",
type: "registry:lib" as const,
type: "registry:file" as const,
},
],
}
// The function should still return true - path validation is handled elsewhere
expect(isUniversalRegistryItem(registryItem)).toBe(true)
})
it("should return false when files have non-registry:file type in a UI registry item", () => {
const registryItem = {
type: "registry:ui" as const,
files: [
{
path: "button.tsx",
target: "src/components/ui/button.tsx",
type: "registry:ui" as const, // Not registry:file
},
],
}
expect(isUniversalRegistryItem(registryItem)).toBe(false)
})
})

View File

@@ -259,7 +259,9 @@ export function isLocalFile(path: string) {
/**
* Check if a registry item is universal (framework-agnostic).
* A universal registry item has all files with explicit targets.
* A universal registry item must have all files with:
* 1. Explicit targets
* 2. Type "registry:file"
* It can be installed without framework detection or components.json.
*/
export function isUniversalRegistryItem(
@@ -270,6 +272,8 @@ export function isUniversalRegistryItem(
): boolean {
return (
!!registryItem?.files?.length &&
registryItem.files.every((file) => !!file.target)
registryItem.files.every(
(file) => !!file.target && file.type === "registry:file"
)
)
}

View File

@@ -1,6 +1,7 @@
import path from "path"
import {
fetchRegistry,
getRegistryItem,
getRegistryParentMap,
getRegistryTypeAliasMap,
registryResolveItemsTree,
@@ -26,6 +27,7 @@ import { spinner } from "@/src/utils/spinner"
import { updateCss } from "@/src/utils/updaters/update-css"
import { updateCssVars } from "@/src/utils/updaters/update-css-vars"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import { updateEnvVars } from "@/src/utils/updaters/update-env-vars"
import { updateFiles } from "@/src/utils/updaters/update-files"
import { updateTailwindConfig } from "@/src/utils/updaters/update-tailwind-config"
import { z } from "zod"
@@ -115,6 +117,10 @@ async function addProjectComponents(
silent: options.silent,
})
await updateEnvVars(tree.envVars, config, {
silent: options.silent,
})
await updateDependencies(tree.dependencies, tree.devDependencies, config, {
silent: options.silent,
})
@@ -231,7 +237,14 @@ async function addWorkspaceComponents(
)
}
// 4. Update dependencies.
// 4. Update environment variables
if (component.envVars) {
await updateEnvVars(component.envVars, targetConfig, {
silent: true,
})
}
// 5. Update dependencies.
await updateDependencies(
component.dependencies,
component.devDependencies,
@@ -241,7 +254,7 @@ async function addWorkspaceComponents(
}
)
// 5. Update files.
// 6. Update files.
const files = await updateFiles(component.files, targetConfig, {
overwrite: options.overwrite,
silent: true,
@@ -327,8 +340,9 @@ async function shouldOverwriteCssVars(
components: z.infer<typeof registryItemSchema>["name"][],
config: z.infer<typeof configSchema>
) {
let registryItems = await resolveRegistryItems(components, config)
let result = await fetchRegistry(registryItems)
let result = await Promise.all(
components.map((component) => getRegistryItem(component, config.style))
)
const payload = z.array(registryItemSchema).parse(result)
return payload.some(

View File

@@ -0,0 +1,369 @@
import { existsSync } from "fs"
import { beforeEach, describe, expect, test, vi } from "vitest"
import {
findExistingEnvFile,
getNewEnvKeys,
isEnvFile,
mergeEnvContent,
parseEnvContent,
} from "./env-helpers"
// Mock fs module
vi.mock("fs", () => ({
existsSync: vi.fn(),
}))
describe("isEnvFile", () => {
test("should identify .env files", () => {
expect(isEnvFile("/path/to/.env")).toBe(true)
expect(isEnvFile(".env")).toBe(true)
expect(isEnvFile("/path/to/.env.local")).toBe(true)
expect(isEnvFile(".env.local")).toBe(true)
expect(isEnvFile(".env.example")).toBe(true)
expect(isEnvFile(".env.development.local")).toBe(true)
expect(isEnvFile(".env.production.local")).toBe(true)
expect(isEnvFile(".env.test.local")).toBe(true)
})
test("should not identify non-.env files", () => {
expect(isEnvFile("/path/to/file.txt")).toBe(false)
expect(isEnvFile("environment.ts")).toBe(false)
expect(isEnvFile("/path/to/.environment")).toBe(false)
expect(isEnvFile("env.config")).toBe(false)
})
})
describe("parseEnvContent", () => {
test("should parse basic key-value pairs", () => {
const content = `KEY1=value1
KEY2=value2`
expect(parseEnvContent(content)).toEqual({
KEY1: "value1",
KEY2: "value2",
})
})
test("should handle comments and empty lines", () => {
const content = `# This is a comment
KEY1=value1
# Another comment
KEY2=value2
`
expect(parseEnvContent(content)).toEqual({
KEY1: "value1",
KEY2: "value2",
})
})
test("should handle quoted values", () => {
const content = `KEY1="value with spaces"
KEY2='single quotes'`
expect(parseEnvContent(content)).toEqual({
KEY1: "value with spaces",
KEY2: "single quotes",
})
})
test("should handle values with equals signs", () => {
const content = `DATABASE_URL=postgresql://user:pass@host:5432/db?ssl=true`
expect(parseEnvContent(content)).toEqual({
DATABASE_URL: "postgresql://user:pass@host:5432/db?ssl=true",
})
})
test("should handle empty values", () => {
const content = `EMPTY_KEY=
KEY_WITH_VALUE=value`
expect(parseEnvContent(content)).toEqual({
EMPTY_KEY: "",
KEY_WITH_VALUE: "value",
})
})
test("should skip malformed lines", () => {
const content = `VALID_KEY=value
this is not a valid line
ANOTHER_KEY=another_value`
expect(parseEnvContent(content)).toEqual({
VALID_KEY: "value",
ANOTHER_KEY: "another_value",
})
})
test("should handle multi-line values (current limitation: breaks them)", () => {
// This test documents that multi-line values are NOT properly supported
const content = `SINGLE_LINE="This is fine"
MULTI_LINE="This is line 1
This is line 2
This is line 3"
NEXT_KEY=value`
const result = parseEnvContent(content)
// Current behavior: only gets first line of multi-line value
expect(result.SINGLE_LINE).toBe("This is fine")
expect(result.MULTI_LINE).toBe("This is line 1")
// The other lines are lost/treated as malformed
expect(result["This is line 2"]).toBeUndefined()
expect(result.NEXT_KEY).toBe("value")
})
test("should handle escaped newlines in values", () => {
const content = `KEY_WITH_ESCAPED_NEWLINE="Line 1\\nLine 2\\nLine 3"
REGULAR_KEY=regular_value`
const result = parseEnvContent(content)
// Escaped newlines are preserved as literal \n
expect(result.KEY_WITH_ESCAPED_NEWLINE).toBe("Line 1\\nLine 2\\nLine 3")
expect(result.REGULAR_KEY).toBe("regular_value")
})
test("should handle values with unmatched quotes", () => {
const content = `GOOD_KEY="proper quotes"
BAD_KEY="unmatched quote
NEXT_KEY=value`
const result = parseEnvContent(content)
expect(result.GOOD_KEY).toBe("proper quotes")
// Current behavior: strips the opening quote even if unmatched
expect(result.BAD_KEY).toBe("unmatched quote")
expect(result.NEXT_KEY).toBe("value")
})
test("should handle backtick quotes (not supported)", () => {
const content = 'KEY1=`backtick value`\nKEY2="double quotes"'
const result = parseEnvContent(content)
// Backticks are not treated as quotes
expect(result.KEY1).toBe("`backtick value`")
expect(result.KEY2).toBe("double quotes")
})
})
describe("mergeEnvContent", () => {
test("should append only new keys", () => {
const existing = `KEY1=value1`
const newContent = `KEY2=value2`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`KEY1=value1
KEY2=value2
`)
})
test("should preserve existing values and NOT overwrite them", () => {
const existing = `KEY1=existing_value
KEY2=value2`
const newContent = `KEY1=new_value_should_be_ignored
KEY3=value3`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`KEY1=existing_value
KEY2=value2
KEY3=value3
`)
expect(result).toContain("KEY1=existing_value")
expect(result).not.toContain("KEY1=new_value_should_be_ignored")
})
test("should handle empty existing content", () => {
const existing = ""
const newContent = "KEY1=value1"
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`KEY1=value1
`)
})
test("should not add any content if all keys already exist", () => {
const existing = `KEY1=value1
KEY2=value2`
const newContent = `KEY1=ignored
KEY2=ignored`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`KEY1=value1
KEY2=value2
`)
})
test("should return unchanged content when all keys exist and formatting is correct", () => {
const existing = `KEY1=value1
KEY2=value2
`
const newContent = `KEY1=ignored
KEY2=ignored`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(existing)
})
test("should handle existing content with comments", () => {
const existing = `# Production configuration
KEY1=value1
# API Keys
KEY2=value2`
const newContent = `KEY3=value3
KEY1=should_be_ignored`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`# Production configuration
KEY1=value1
# API Keys
KEY2=value2
KEY3=value3
`)
})
test("should maintain proper formatting", () => {
const existing = `KEY1=value1
KEY2=value2
`
const newContent = `KEY3=value3`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`KEY1=value1
KEY2=value2
KEY3=value3
`)
})
test("should handle multiple new keys", () => {
const existing = `KEY1=value1`
const newContent = `KEY2=value2
KEY3=value3
KEY4=value4`
const result = mergeEnvContent(existing, newContent)
expect(result).toBe(`KEY1=value1
KEY2=value2
KEY3=value3
KEY4=value4
`)
})
test("should handle multi-line values in merge (current limitation)", () => {
const existing = `EXISTING_KEY=existing_value`
const newContent = `MULTI_LINE_KEY="Line 1
Line 2
Line 3"
SIMPLE_KEY=simple`
const result = mergeEnvContent(existing, newContent)
// Current behavior: only the first line is added
expect(result).toBe(`EXISTING_KEY=existing_value
MULTI_LINE_KEY=Line 1
SIMPLE_KEY=simple
`)
// The multi-line value is broken
expect(result).not.toContain("Line 2")
expect(result).not.toContain("Line 3")
})
})
describe("getNewEnvKeys", () => {
test("should identify new keys", () => {
const existing = `KEY1=value1
KEY2=value2`
const newContent = `KEY1=ignored
KEY3=value3
KEY4=value4`
const result = getNewEnvKeys(existing, newContent)
expect(result).toEqual(["KEY3", "KEY4"])
})
test("should return empty array when all keys exist", () => {
const existing = `KEY1=value1
KEY2=value2`
const newContent = `KEY1=different
KEY2=different`
const result = getNewEnvKeys(existing, newContent)
expect(result).toEqual([])
})
test("should handle empty existing content", () => {
const existing = ""
const newContent = `KEY1=value1
KEY2=value2`
const result = getNewEnvKeys(existing, newContent)
expect(result).toEqual(["KEY1", "KEY2"])
})
})
describe("findExistingEnvFile", () => {
beforeEach(() => {
vi.clearAllMocks()
})
test("should return .env.local if it exists", () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env.local")
})
const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledTimes(1)
})
test("should return .env if .env.local doesn't exist", () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env")
})
const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledTimes(2)
})
test("should return .env.development.local if earlier variants don't exist", () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env.development.local")
})
const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env.development.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
expect(existsSync).toHaveBeenCalledTimes(3)
})
test("should return null if no env files exist", () => {
vi.mocked(existsSync).mockReturnValue(false)
const result = findExistingEnvFile("/test/dir")
expect(result).toBeNull()
expect(existsSync).toHaveBeenCalledTimes(4)
})
test("should check all variants in correct order", () => {
vi.mocked(existsSync).mockReturnValue(false)
findExistingEnvFile("/test/dir")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development")
})
})

View File

@@ -0,0 +1,113 @@
import { existsSync } from "fs"
import path from "path"
export function isEnvFile(filePath: string) {
const fileName = path.basename(filePath)
return /^\.env(\.|$)/.test(fileName)
}
/**
* Finds a file variant in the project.
* TODO: abstract this to a more generic function.
*/
export function findExistingEnvFile(targetDir: string) {
const variants = [
".env.local",
".env",
".env.development.local",
".env.development",
]
for (const variant of variants) {
const filePath = path.join(targetDir, variant)
if (existsSync(filePath)) {
return filePath
}
}
return null
}
/**
* Parse .env content into key-value pairs.
*/
export function parseEnvContent(content: string) {
const lines = content.split("\n")
const env: Record<string, string> = {}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) {
continue
}
// Find the first = and split there
const equalIndex = trimmed.indexOf("=")
if (equalIndex === -1) {
continue
}
const key = trimmed.substring(0, equalIndex).trim()
const value = trimmed.substring(equalIndex + 1).trim()
if (key) {
env[key] = value.replace(/^["']|["']$/g, "")
}
}
return env
}
/**
* Get the list of new keys that would be added when merging env content.
*/
export function getNewEnvKeys(existingContent: string, newContent: string) {
const existingEnv = parseEnvContent(existingContent)
const newEnv = parseEnvContent(newContent)
const newKeys = []
for (const key of Object.keys(newEnv)) {
if (!(key in existingEnv)) {
newKeys.push(key)
}
}
return newKeys
}
/**
* Merge env content by appending ONLY new keys that don't exist in the existing content.
* Existing keys are preserved with their original values.
*/
export function mergeEnvContent(existingContent: string, newContent: string) {
const existingEnv = parseEnvContent(existingContent)
const newEnv = parseEnvContent(newContent)
let result = existingContent.trimEnd()
if (result && !result.endsWith("\n")) {
result += "\n"
}
const newKeys: string[] = []
for (const [key, value] of Object.entries(newEnv)) {
if (!(key in existingEnv)) {
newKeys.push(`${key}=${value}`)
}
}
if (newKeys.length > 0) {
if (result) {
result += "\n"
}
result += newKeys.join("\n")
return result + "\n"
}
// Ensure existing content ends with newline.
if (result && !result.endsWith("\n")) {
return result + "\n"
}
return result
}

View File

@@ -0,0 +1,183 @@
import { existsSync, promises as fs } from "fs"
import type { Config } from "@/src/utils/get-config"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import { updateEnvVars } from "./update-env-vars"
vi.mock("fs", () => ({
existsSync: vi.fn(),
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
info: vi.fn(),
log: vi.fn(),
success: vi.fn(),
break: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn(),
succeed: vi.fn(),
})),
}))
const mockConfig: Config = {
style: "default",
rsc: false,
tailwind: {
config: "tailwind.config.js",
css: "app/globals.css",
baseColor: "slate",
prefix: "",
cssVariables: false,
},
tsx: true,
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
utils: "@/utils",
},
resolvedPaths: {
cwd: "/test/project",
tailwindConfig: "/test/project/tailwind.config.js",
tailwindCss: "/test/project/app/globals.css",
components: "/test/project/components",
ui: "/test/project/components/ui",
lib: "/test/project/lib",
hooks: "/test/project/hooks",
utils: "/test/project/utils",
},
}
describe("updateEnvVars", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.resetAllMocks()
})
test("should create new .env.local file when none exists", async () => {
vi.mocked(existsSync).mockReturnValue(false)
const envVars = {
API_KEY: "test-key",
API_URL: "https://api.example.com",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(
"/test/project/.env.local",
"API_KEY=test-key\nAPI_URL=https://api.example.com\n",
"utf-8"
)
expect(result).toEqual({
envVarsAdded: ["API_KEY", "API_URL"],
envFileUpdated: null,
envFileCreated: ".env.local",
})
})
test("should update existing .env.local file with new variables", async () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue("EXISTING_KEY=existing-value\n")
const envVars = {
NEW_KEY: "new-value",
ANOTHER_KEY: "another-value",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(
"/test/project/.env.local",
"EXISTING_KEY=existing-value\n\nNEW_KEY=new-value\nANOTHER_KEY=another-value\n",
"utf-8"
)
expect(result).toEqual({
envVarsAdded: ["NEW_KEY", "ANOTHER_KEY"],
envFileUpdated: ".env.local",
envFileCreated: null,
})
})
test("should skip when all variables already exist", async () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue(
"API_KEY=existing-key\nAPI_URL=existing-url\n"
)
const envVars = {
API_KEY: "new-key",
API_URL: "new-url",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalled()
expect(result).toEqual({
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
})
})
test("should find and use .env.local when .env doesn't exist", async () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env.local")
})
vi.mocked(fs.readFile).mockResolvedValue("EXISTING_VAR=value\n")
const envVars = {
NEW_VAR: "new-value",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(
"/test/project/.env.local",
"EXISTING_VAR=value\n\nNEW_VAR=new-value\n",
"utf-8"
)
expect(result).toEqual({
envVarsAdded: ["NEW_VAR"],
envFileUpdated: ".env.local",
envFileCreated: null,
})
})
test("should return early when no env vars provided", async () => {
const result = await updateEnvVars(undefined, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalled()
expect(result).toEqual({
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
})
})
test("should return early when empty env vars object provided", async () => {
const result = await updateEnvVars({}, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalled()
expect(result).toEqual({
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
})
})
})

View File

@@ -0,0 +1,108 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { registryItemEnvVarsSchema } from "@/src/registry/schema"
import {
findExistingEnvFile,
getNewEnvKeys,
mergeEnvContent,
} from "@/src/utils/env-helpers"
import { Config } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { z } from "zod"
export async function updateEnvVars(
envVars: z.infer<typeof registryItemEnvVarsSchema> | undefined,
config: Config,
options: {
silent?: boolean
}
) {
if (!envVars || Object.keys(envVars).length === 0) {
return {
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
}
}
options = {
silent: false,
...options,
}
const envSpinner = spinner(`Adding environment variables.`, {
silent: options.silent,
})?.start()
const projectRoot = config.resolvedPaths.cwd
// Find existing env file or use .env.local as default.
let envFilePath = path.join(projectRoot, ".env.local")
const existingEnvFile = findExistingEnvFile(projectRoot)
if (existingEnvFile) {
envFilePath = existingEnvFile
}
const envFileExists = existsSync(envFilePath)
const envFileName = path.basename(envFilePath)
// Convert envVars object to env file format
const newEnvContent = Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join("\n")
let envVarsAdded: string[] = []
let envFileUpdated: string | null = null
let envFileCreated: string | null = null
if (envFileExists) {
const existingContent = await fs.readFile(envFilePath, "utf-8")
const mergedContent = mergeEnvContent(existingContent, newEnvContent)
envVarsAdded = getNewEnvKeys(existingContent, newEnvContent)
if (envVarsAdded.length > 0) {
await fs.writeFile(envFilePath, mergedContent, "utf-8")
envFileUpdated = path.relative(projectRoot, envFilePath)
envSpinner?.succeed(
`Added the following variables to ${highlighter.info(envFileName)}:`
)
if (!options.silent) {
for (const key of envVarsAdded) {
logger.log(` ${highlighter.success("+")} ${key}`)
}
}
} else {
envSpinner?.stop()
}
} else {
// Create new env file
await fs.writeFile(envFilePath, newEnvContent + "\n", "utf-8")
envFileCreated = path.relative(projectRoot, envFilePath)
envVarsAdded = Object.keys(envVars)
envSpinner?.succeed(
`Added the following variables to ${highlighter.info(envFileName)}:`
)
if (!options.silent) {
for (const key of envVarsAdded) {
logger.log(` ${highlighter.success("+")} ${key}`)
}
}
}
if (!options.silent && envVarsAdded.length > 0) {
logger.break()
}
return {
envVarsAdded,
envFileUpdated,
envFileCreated,
}
}

View File

@@ -3,6 +3,13 @@ import { tmpdir } from "os"
import path, { basename } from "path"
import { getRegistryBaseColor } from "@/src/registry/api"
import { RegistryItem, registryItemFileSchema } from "@/src/registry/schema"
import {
findExistingEnvFile,
getNewEnvKeys,
isEnvFile,
mergeEnvContent,
parseEnvContent,
} from "@/src/utils/env-helpers"
import { Config } from "@/src/utils/get-config"
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
@@ -59,6 +66,8 @@ export async function updateFiles(
let filesCreated: string[] = []
let filesUpdated: string[] = []
let filesSkipped: string[] = []
let envVarsAdded: string[] = []
let envFile: string | null = null
for (const file of files) {
if (!file.content) {
@@ -87,29 +96,40 @@ export async function updateFiles(
)
}
if (isEnvFile(filePath) && !existsSync(filePath)) {
const alternativeEnvFile = findExistingEnvFile(targetDir)
if (alternativeEnvFile) {
filePath = alternativeEnvFile
}
}
const existingFile = existsSync(filePath)
// Run our transformers.
const content = await transform(
{
filename: file.path,
raw: file.content,
config,
baseColor,
transformJsx: !config.tsx,
isRemote: options.isRemote,
},
[
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
transformIcons,
]
)
// Skip transformers for .env files to preserve exact content
const content = isEnvFile(filePath)
? file.content
: await transform(
{
filename: file.path,
raw: file.content,
config,
baseColor,
transformJsx: !config.tsx,
isRemote: options.isRemote,
},
[
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
transformIcons,
]
)
// Skip the file if it already exists and the content is the same.
if (existingFile) {
// Exception: Don't skip .env files as we merge content instead of replacing
if (existingFile && !isEnvFile(filePath)) {
const existingFileContent = await fs.readFile(filePath, "utf-8")
const [normalizedExisting, normalizedNew] = await Promise.all([
getNormalizedFileContent(existingFileContent),
@@ -121,7 +141,8 @@ export async function updateFiles(
}
}
if (existingFile && !options.overwrite) {
// Skip overwrite prompt for .env files - we'll handle them specially
if (existingFile && !options.overwrite && !isEnvFile(filePath)) {
filesCreatedSpinner.stop()
if (options.rootSpinner) {
options.rootSpinner.stop()
@@ -153,10 +174,36 @@ export async function updateFiles(
await fs.mkdir(targetDir, { recursive: true })
}
// Special handling for .env files - append only new keys
if (isEnvFile(filePath) && existingFile) {
const existingFileContent = await fs.readFile(filePath, "utf-8")
const mergedContent = mergeEnvContent(existingFileContent, content)
envVarsAdded = getNewEnvKeys(existingFileContent, content)
envFile = path.relative(config.resolvedPaths.cwd, filePath)
if (!envVarsAdded.length) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
await fs.writeFile(filePath, mergedContent, "utf-8")
filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
await fs.writeFile(filePath, content, "utf-8")
existingFile
? filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
: filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath))
// Handle file creation logging
if (!existingFile) {
filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath))
if (isEnvFile(filePath)) {
envVarsAdded = Object.keys(parseEnvContent(content))
envFile = path.relative(config.resolvedPaths.cwd, filePath)
}
} else {
filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
}
}
const allFiles = [...filesCreated, ...filesUpdated, ...filesSkipped]
@@ -225,6 +272,17 @@ export async function updateFiles(
}
}
if (envVarsAdded.length && envFile) {
spinner(
`Added the following variables to ${highlighter.info(envFile)}:`
)?.info()
if (!options.silent) {
for (const key of envVarsAdded) {
logger.log(` ${highlighter.success("+")} ${key}`)
}
}
}
if (!options.silent) {
logger.break()
}
@@ -397,7 +455,7 @@ async function resolveImports(filePaths: string[], config: Config) {
compilerOptions: {},
})
const projectInfo = await getProjectInfo(config.resolvedPaths.cwd)
const tsConfig = await loadConfig(config.resolvedPaths.cwd)
const tsConfig = loadConfig(config.resolvedPaths.cwd)
const updatedFiles = []
if (!projectInfo || tsConfig.resultType === "failed") {

View File

@@ -1,201 +0,0 @@
import fs from "fs"
import path from "path"
import { execa } from "execa"
import { afterEach, expect, test, vi } from "vitest"
import { runInit } from "../../src/commands/init"
import * as registry from "../../src/registry"
import { getConfig } from "../../src/utils/get-config"
import * as getPackageManger from "../../src/utils/get-package-manager"
vi.mock("execa")
vi.mock("fs/promises", () => ({
writeFile: vi.fn(),
mkdir: vi.fn(),
}))
vi.mock("ora")
test.skip("init config-full", async () => {
vi.spyOn(getPackageManger, "getPackageManager").mockResolvedValue("pnpm")
vi.spyOn(registry, "getRegistryBaseColor").mockResolvedValue({
inlineColors: {},
cssVars: {},
inlineColorsTemplate:
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n",
cssVarsTemplate:
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n",
})
vi.spyOn(registry, "getRegistryItem").mockResolvedValue({
name: "new-york",
dependencies: [
"tailwindcss-animate",
"class-variance-authority",
"clsx",
"tailwind-merge",
"lucide-react",
"@radix-ui/react-icons",
],
registryDependencies: [],
tailwind: {
config: {
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: ['require("tailwindcss-animate")'],
},
},
files: [],
cssVariables: {
light: {
"--radius": "0.5rem",
},
},
})
const mockMkdir = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined)
const mockWriteFile = vi.spyOn(fs.promises, "writeFile").mockResolvedValue()
const targetDir = path.resolve(__dirname, "../fixtures/config-full")
const config = await getConfig(targetDir)
await runInit(config)
expect(mockMkdir).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/src\/lib$/),
expect.anything()
)
expect(mockMkdir).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/src\/components$/),
expect.anything()
)
expect(mockWriteFile).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/src\/app\/globals.css$/),
expect.stringContaining(`@tailwind base`),
"utf8"
)
expect(mockWriteFile).toHaveBeenNthCalledWith(
3,
expect.stringMatching(/src\/lib\/utils.ts$/),
expect.stringContaining(`import { type ClassValue, clsx } from "clsx"`),
"utf8"
)
expect(execa).toHaveBeenCalledWith(
"pnpm",
[
"add",
"tailwindcss-animate",
"class-variance-authority",
"clsx",
"tailwind-merge",
"lucide-react",
"@radix-ui/react-icons",
],
{
cwd: targetDir,
}
)
mockMkdir.mockRestore()
mockWriteFile.mockRestore()
})
test.skip("init config-partial", async () => {
vi.spyOn(getPackageManger, "getPackageManager").mockResolvedValue("npm")
vi.spyOn(registry, "getRegistryBaseColor").mockResolvedValue({
inlineColors: {},
cssVars: {},
inlineColorsTemplate:
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n",
cssVarsTemplate:
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n",
})
vi.spyOn(registry, "getRegistryItem").mockResolvedValue({
name: "new-york",
dependencies: [
"tailwindcss-animate",
"class-variance-authority",
"clsx",
"tailwind-merge",
"lucide-react",
],
registryDependencies: [],
tailwind: {
config: {
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: ['require("tailwindcss-animate")'],
},
},
files: [],
cssVariables: {
light: {
"--radius": "0.5rem",
},
},
})
const mockMkdir = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined)
const mockWriteFile = vi.spyOn(fs.promises, "writeFile").mockResolvedValue()
const targetDir = path.resolve(__dirname, "../fixtures/config-partial")
const config = await getConfig(targetDir)
await runInit(config)
expect(mockMkdir).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/src\/assets\/css$/),
expect.anything()
)
expect(mockMkdir).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/lib$/),
expect.anything()
)
expect(mockMkdir).toHaveBeenNthCalledWith(
3,
expect.stringMatching(/components$/),
expect.anything()
)
expect(mockWriteFile).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/utils.ts$/),
expect.stringContaining(`import { type ClassValue, clsx } from "clsx"`),
"utf8"
)
expect(execa).toHaveBeenCalledWith(
"npm",
[
"install",
"tailwindcss-animate",
"class-variance-authority",
"clsx",
"tailwind-merge",
"lucide-react",
],
{
cwd: targetDir,
}
)
mockMkdir.mockRestore()
mockWriteFile.mockRestore()
})
afterEach(() => {
vi.resetAllMocks()
})

View File

@@ -1,4 +1,4 @@
import { existsSync } from "fs"
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterAll, afterEach, describe, expect, test, vi } from "vitest"
@@ -16,9 +16,12 @@ vi.mock("fs/promises", async () => {
const actual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
return {
...actual,
writeFile: vi.fn(),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockImplementation(actual.readFile),
mkdir: vi.fn().mockResolvedValue(undefined),
}
})
@@ -26,15 +29,21 @@ vi.mock("fs", async () => {
const actual = (await vi.importActual("fs")) as typeof import("fs")
return {
...actual,
existsSync: vi.fn().mockImplementation(actual.existsSync),
promises: {
...actual.promises,
writeFile: vi.fn(),
writeFile: vi.fn().mockResolvedValue(undefined),
},
}
})
afterEach(() => {
vi.resetAllMocks()
vi.mock("prompts")
afterEach(async () => {
vi.clearAllMocks()
// Restore the actual implementation of existsSync after clearing mocks
const actual = (await vi.importActual("fs")) as typeof import("fs")
vi.mocked(existsSync).mockImplementation(actual.existsSync)
})
afterAll(() => {
@@ -811,6 +820,346 @@ return <div>Hello World</div>
}
`)
})
test("should mark .env file as created when it doesn't exist", async () => {
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: ".env",
type: "registry:file",
target: "~/.env",
content: `NEW_API_KEY=new_api_key_value
ANOTHER_NEW_KEY=another_value`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
expect(result.filesCreated).toContain(".env")
expect(result.filesUpdated).not.toContain(".env")
})
test("should mark .env file as updated when merging content", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-env-test"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const writeFileMock = fs.writeFile as any
try {
await fsActual.mkdir(tempDir, { recursive: true })
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify({
$schema: "https://ui.shadcn.com/schema.json",
style: "default",
tailwind: {
config: "tailwind.config.js",
css: "src/index.css",
baseColor: "slate",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
}),
"utf-8"
)
const config = await getConfig(tempDir)
const envPath = path.join(config?.resolvedPaths.cwd!, ".env")
await fsActual.writeFile(
envPath,
`EXISTING_KEY=existing_value
DATABASE_URL=postgres://localhost:5432/mydb`,
"utf-8"
)
const result = await updateFiles(
[
{
path: ".env",
type: "registry:file",
target: "~/.env",
content: `DATABASE_URL=should_not_override
NEW_API_KEY=new_api_key_value
ANOTHER_NEW_KEY=another_value`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
expect(result.filesUpdated).toContain(".env")
expect(result.filesCreated).not.toContain(".env")
// Verify writeFile was called with the correct merged content.
expect(writeFileMock).toHaveBeenCalledWith(
envPath,
`EXISTING_KEY=existing_value
DATABASE_URL=postgres://localhost:5432/mydb
NEW_API_KEY=new_api_key_value
ANOTHER_NEW_KEY=another_value
`,
"utf-8"
)
} finally {
await fsActual.rm(tempDir, { recursive: true }).catch(() => {})
}
})
test("should use .env.local when .env doesn't exist", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-env-alternative-test"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const writeFileMock = fs.writeFile as any
try {
await fsActual.mkdir(tempDir, { recursive: true })
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify({
$schema: "https://ui.shadcn.com/schema.json",
style: "default",
tailwind: {
config: "tailwind.config.js",
css: "src/index.css",
baseColor: "slate",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
}),
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
const envLocalPath = path.join(config.resolvedPaths.cwd, ".env.local")
// Create .env.local instead of .env
await fsActual.writeFile(
envLocalPath,
`EXISTING_KEY=existing_value
DATABASE_URL=postgres://localhost:5432/mydb`,
"utf-8"
)
const result = await updateFiles(
[
{
path: ".env",
type: "registry:file",
target: "~/.env",
content: `DATABASE_URL=should_not_override
NEW_API_KEY=new_api_key_value`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
expect(result.filesUpdated).toContain(".env.local")
expect(result.filesCreated).not.toContain(".env")
expect(result.filesCreated).not.toContain(".env.local")
expect(writeFileMock).toHaveBeenCalledWith(
envLocalPath,
`EXISTING_KEY=existing_value
DATABASE_URL=postgres://localhost:5432/mydb
NEW_API_KEY=new_api_key_value
`,
"utf-8"
)
} finally {
await fsActual.rm(tempDir, { recursive: true }).catch(() => {})
}
})
test("should use existing .env when target is .env.local but doesn't exist", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-env-target-local-test"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const writeFileMock = fs.writeFile as any
try {
await fsActual.mkdir(tempDir, { recursive: true })
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify({
$schema: "https://ui.shadcn.com/schema.json",
style: "default",
tailwind: {
config: "tailwind.config.js",
css: "src/index.css",
baseColor: "slate",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
}),
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
const envPath = path.join(config.resolvedPaths.cwd, ".env")
// Create .env file (not .env.local)
await fsActual.writeFile(envPath, `EXISTING_KEY=existing_value`, "utf-8")
const result = await updateFiles(
[
{
path: ".env.local",
type: "registry:file",
target: "~/.env.local",
content: `NEW_KEY=new_value`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Should update .env instead of creating .env.local
expect(result.filesUpdated).toContain(".env")
expect(result.filesCreated).not.toContain(".env.local")
expect(writeFileMock).toHaveBeenCalledWith(
envPath,
`EXISTING_KEY=existing_value
NEW_KEY=new_value
`,
"utf-8"
)
} finally {
await fsActual.rm(tempDir, { recursive: true }).catch(() => {})
}
})
test("should create .env when no env variants exist", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-env-create-test"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const writeFileMock = fs.writeFile as any
try {
await fsActual.mkdir(tempDir, { recursive: true })
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify({
$schema: "https://ui.shadcn.com/schema.json",
style: "default",
tailwind: {
config: "tailwind.config.js",
css: "src/index.css",
baseColor: "slate",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
}),
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
const envPath = path.join(config.resolvedPaths.cwd, ".env")
// Ensure no env files exist
const envVariants = [
".env",
".env.local",
".env.development.local",
".env.development",
]
for (const variant of envVariants) {
const variantPath = path.join(config.resolvedPaths.cwd, variant)
await fsActual.unlink(variantPath).catch(() => {})
}
const result = await updateFiles(
[
{
path: ".env",
type: "registry:file",
target: "~/.env",
content: `NEW_API_KEY=new_api_key_value
DATABASE_URL=postgres://localhost:5432/mydb`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
expect(result.filesCreated).toContain(".env")
expect(result.filesUpdated).not.toContain(".env")
expect(result.filesUpdated).not.toContain(".env.local")
expect(writeFileMock).toHaveBeenCalledWith(
envPath,
`NEW_API_KEY=new_api_key_value
DATABASE_URL=postgres://localhost:5432/mydb`,
"utf-8"
)
} finally {
await fsActual.rm(tempDir, { recursive: true }).catch(() => {})
}
})
})
describe("resolveModuleByProbablePath", () => {

View File

@@ -0,0 +1,2 @@
fixtures/
temp/

3
packages/tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
temp/
*.log
.cache/

34
packages/tests/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Tests
This package contains integration tests that verify the shadcn CLI works correctly with a local registry. The tests run actual CLI commands against test fixtures to ensure files are created and updated properly.
## Running Tests
Run the following command from the root of the workspace:
```bash
pnpm tests:test
```
## Writing Tests
```typescript
import {
createFixtureTestDirectory,
fileExists,
npxShadcn,
} from "../utils/helpers"
describe("my test suite", () => {
it("should do something", async () => {
// Create a test directory from a fixture
const testDir = await createFixtureTestDirectory("next-app")
// Run CLI command
await npxShadcn(testDir, ["init", "--base-color=neutral"])
// Make assertions
expect(await fileExists(path.join(testDir, "components.json"))).toBe(true)
})
})
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,22 @@
import "./globals.css"
import type { Metadata } from "next"
import { Inter } from "next/font/google"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
body {
background-color: red;
}

View File

@@ -0,0 +1,113 @@
import Image from "next/image"
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
Get started by editing&nbsp;
<code className="font-mono font-bold">app/page.tsx</code>
</p>
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
<a
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{" "}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className="dark:invert"
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Docs{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Find in-depth information about Next.js features and API.
</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Learn{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Learn about Next.js in an interactive course with&nbsp;quizzes!
</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Templates{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Explore the Next.js 13 playground.
</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Deploy{" "}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
)
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,32 @@
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.527.0",
"next": "15.4.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.6",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "example-component",
"type": "registry:component",
"files": [
{
"path": "example/hello-world.tsx",
"type": "registry:component",
"content": "console.log('Hello, world!')"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "example-env-vars",
"type": "registry:item",
"envVars": {
"APP_URL": "https://example.com",
"EMPTY_VAR": "",
"MULTILINE_VAR": "\"line1\nline2\nline3\""
}
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "example-item-to-root",
"type": "registry:item",
"files": [
{
"path": "example/config.json",
"type": "registry:item",
"content": "{\"foo\": \"bar\"}",
"target": "~/config.json"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "example-item",
"type": "registry:item",
"files": [
{
"path": "example/foo.txt",
"type": "registry:item",
"content": "Foo Bar",
"target": "path/to/foo.txt"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "example-style",
"type": "registry:style",
"dependencies": ["@tabler/icons-react"],
"cssVars": {
"theme": {
"font-sans": "Inter, sans-serif"
},
"light": {
"brand": "oklch(20 14.3% 4.1%)",
"brand-foreground": "oklch(24 1.3% 10%)"
},
"dark": {
"brand": "oklch(24 1.3% 10%)"
}
}
}

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
{
"name": "vite-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,36 @@
import { useState } from "react"
import reactLogo from "./assets/react.svg"
import viteLogo from "/vite.svg"
import "./App.css"
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
import App from "./App.tsx"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Paths */
"baseUrl": ".",
"paths": {
"#custom/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#custom/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,14 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"#custom": path.resolve(__dirname, "./src"),
},
},
})

View File

@@ -0,0 +1,26 @@
{
"name": "tests",
"version": "0.0.1",
"private": true,
"description": "Integration tests for shadcn CLI",
"type": "module",
"scripts": {
"test": "vitest run",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"shadcn": "workspace:*"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.11.27",
"execa": "^7.0.0",
"fs-extra": "^11.1.0",
"rimraf": "^6.0.1",
"typescript": "^5.5.3",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,281 @@
import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import {
createFixtureTestDirectory,
cssHasProperties,
getRegistryUrl,
npxShadcn,
} from "../utils/helpers"
describe("shadcn add", () => {
it("should add item to project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, ["add", "button"])
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
it("should add multiple items to project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, ["add", "button", "card"])
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/card.tsx"))
).toBe(true)
})
it("should add item from url", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
const registryUrl = getRegistryUrl()
const url = `${registryUrl}/styles/new-york-v4/login-01.json`
await npxShadcn(fixturePath, ["add", url])
expect(
await fs.pathExists(path.join(fixturePath, "app/login/page.tsx"))
).toBe(true)
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/card.tsx"))
).toBe(true)
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/input.tsx"))
).toBe(true)
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/label.tsx"))
).toBe(true)
})
it("should add component from local file", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-component.json",
])
const helloWorldContent = await fs.readFile(
path.join(fixturePath, "components/hello-world.tsx"),
"utf-8"
)
expect(helloWorldContent).toBe("console.log('Hello, world!')")
})
it("should add registry:page to the correct path", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, ["add", "login-03"])
expect(
await fs.pathExists(path.join(fixturePath, "app/login/page.tsx"))
).toBe(true)
})
it("should add item with registryDependencies", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, ["add", "alert-dialog"])
expect(
await fs.pathExists(
path.join(fixturePath, "components/ui/alert-dialog.tsx")
)
).toBe(true)
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
it("should add item with npm dependencies", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-style.json",
"--yes",
])
const packageJson = await fs.readJson(
path.join(fixturePath, "package.json")
)
expect(packageJson.dependencies["@tabler/icons-react"]).toBeDefined()
})
it("should install cssVars", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-style.json",
"--yes",
])
const globalCssContent = await fs.readFile(
path.join(fixturePath, "app/globals.css"),
"utf-8"
)
expect(
cssHasProperties(globalCssContent, [
{
selector: "@theme inline",
properties: {
"--font-sans": "Inter, sans-serif",
"--color-brand": "var(--brand)",
"--color-brand-foreground": "var(--brand-foreground)",
},
},
{
selector: ":root",
properties: {
"--brand": "oklch(20 14.3% 4.1%)",
"--brand-foreground": "oklch(24 1.3% 10%)",
},
},
{
selector: ".dark",
properties: {
"--brand": "oklch(24 1.3% 10%)",
},
},
])
).toBe(true)
})
it("should add item with target", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-item.json",
])
expect(await fs.pathExists(path.join(fixturePath, "path/to/foo.txt"))).toBe(
true
)
expect(
await fs.readFile(path.join(fixturePath, "path/to/foo.txt"), "utf-8")
).toBe("Foo Bar")
})
it("should add item with target to src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-item.json",
])
expect(
await fs.pathExists(path.join(fixturePath, "src/path/to/foo.txt"))
).toBe(true)
expect(
await fs.readFile(path.join(fixturePath, "src/path/to/foo.txt"), "utf-8")
).toBe("Foo Bar")
})
it("should add item with target to root", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-item-to-root.json",
])
expect(await fs.pathExists(path.join(fixturePath, "config.json"))).toBe(
true
)
expect(await fs.readJson(path.join(fixturePath, "config.json"))).toEqual({
foo: "bar",
})
})
it("should add item with target to root when src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-item-to-root.json",
])
expect(await fs.pathExists(path.join(fixturePath, "config.json"))).toBe(
true
)
expect(await fs.readJson(path.join(fixturePath, "config.json"))).toEqual({
foo: "bar",
})
})
it("should add item with envVars", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-env-vars.json",
])
expect(await fs.pathExists(path.join(fixturePath, ".env.local"))).toBe(true)
expect(await fs.readFile(path.join(fixturePath, ".env.local"), "utf-8"))
.toMatchInlineSnapshot(`
"APP_URL=https://example.com
EMPTY_VAR=
MULTILINE_VAR="line1
line2
line3"
"
`)
})
it("should add NOT update existing envVars", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await fs.writeFile(
path.join(fixturePath, ".env.local"),
"APP_URL=https://foo.com"
)
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-env-vars.json",
])
expect(await fs.pathExists(path.join(fixturePath, ".env.local"))).toBe(true)
expect(await fs.readFile(path.join(fixturePath, ".env.local"), "utf-8"))
.toMatchInlineSnapshot(`
"APP_URL=https://foo.com
EMPTY_VAR=
MULTILINE_VAR=line1
"
`)
})
it("should use existing .env if it exists", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
await fs.writeFile(
path.join(fixturePath, ".env"),
"APP_URL=https://foo.com"
)
await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-env-vars.json",
])
expect(await fs.pathExists(path.join(fixturePath, ".env.local"))).toBe(
false
)
expect(await fs.readFile(path.join(fixturePath, ".env"), "utf-8"))
.toMatchInlineSnapshot(`
"APP_URL=https://foo.com
EMPTY_VAR=
MULTILINE_VAR=line1
"
`)
})
})

View File

@@ -0,0 +1,130 @@
import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
describe("shadcn init - next-app", () => {
it("should init with default configuration", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
const componentsJsonPath = path.join(fixturePath, "components.json")
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
const componentsJson = await fs.readJson(componentsJsonPath)
expect(componentsJson).toMatchObject({
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
})
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
true
)
const cssPath = path.join(fixturePath, "app/globals.css")
const cssContent = await fs.readFile(cssPath, "utf-8")
expect(cssContent).toContain("@layer base")
expect(cssContent).toContain(":root")
expect(cssContent).toContain(".dark")
expect(cssContent).toContain("tw-animate-css")
expect(cssContent).toContain("--background")
expect(cssContent).toContain("--foreground")
})
it("should init with custom base color", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=zinc"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("zinc")
})
it("should init without CSS variables", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, [
"init",
"--base-color=stone",
"--no-css-variables",
])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.tailwind.cssVariables).toBe(false)
const cssPath = path.join(fixturePath, "app/globals.css")
const cssContent = await fs.readFile(cssPath, "utf-8")
expect(cssContent).not.toContain("--background")
expect(cssContent).not.toContain("--foreground")
})
it("should init with components", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--base-color=neutral", "button"])
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
})
describe("shadcn init - vite-app", () => {
it("should init with custom alias and src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--base-color=gray", "alert-dialog"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("gray")
expect(componentsJson.aliases).toMatchObject({
components: "#custom/components",
utils: "#custom/lib/utils",
ui: "#custom/components/ui",
lib: "#custom/lib",
hooks: "#custom/hooks",
})
expect(
await fs.pathExists(
path.join(fixturePath, "src/components/ui/alert-dialog.tsx")
)
).toBe(true)
expect(
await fs.pathExists(
path.join(fixturePath, "src/components/ui/button.tsx")
)
).toBe(true)
const alertDialogContent = await fs.readFile(
path.join(fixturePath, "src/components/ui/alert-dialog.tsx"),
"utf-8"
)
expect(alertDialogContent).toContain(
'import { buttonVariants } from "#custom/components/ui/button"'
)
expect(alertDialogContent).toContain(
'import { cn } from "#custom/lib/utils"'
)
})
})

View File

@@ -0,0 +1,91 @@
import path from "path"
import { fileURLToPath } from "url"
import { execa } from "execa"
import fs from "fs-extra"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.join(__dirname, "../../fixtures")
const TEMP_DIR = path.join(__dirname, "../../temp")
const SHADCN_CLI_PATH = path.join(__dirname, "../../../shadcn/dist/index.js")
export function getRegistryUrl() {
return process.env.REGISTRY_URL || "http://localhost:4000/r"
}
export async function createFixtureTestDirectory(fixtureName: string) {
const fixturePath = path.join(FIXTURES_DIR, fixtureName)
const testDir = path.join(
TEMP_DIR,
`test-${Date.now()}-${Math.random().toString(36).substring(7)}`
)
await fs.ensureDir(TEMP_DIR)
await fs.copy(fixturePath, testDir)
return testDir
}
export async function runCommand(
cwd: string,
args: string[],
options?: {
env?: Record<string, string>
input?: string
}
) {
try {
const childProcess = execa("node", [SHADCN_CLI_PATH, ...args], {
cwd,
env: {
...process.env,
FORCE_COLOR: "0",
CI: "true",
...options?.env,
},
input: options?.input,
reject: false,
timeout: 30000,
})
const result = await childProcess
return {
stdout: result.stdout || "",
stderr: result.stderr || "",
exitCode: result.exitCode ?? 0,
}
} catch (error: any) {
return {
stdout: error.stdout || "",
stderr: error.stderr || error.message || "",
exitCode: error.exitCode ?? 1,
}
}
}
export async function npxShadcn(cwd: string, args: string[]) {
return runCommand(cwd, args, {
env: {
REGISTRY_URL: getRegistryUrl(),
},
})
}
export function cssHasProperties(
cssContent: string,
checks: Array<{
selector: string
properties: Record<string, string>
}>
) {
return checks.every(({ selector, properties }) => {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = new RegExp(`${escapedSelector}\\s*{([^}]+)}`, "s")
const match = cssContent.match(regex)
const block = match ? match[1] : ""
return Object.entries(properties).every(([property, value]) =>
block.includes(`${property}: ${value};`)
)
})
}

View File

@@ -0,0 +1,18 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { tmpdir } from "os"
import path from "path"
import fs from "fs-extra"
import { rimraf } from "rimraf"
const TEMP_DIR = fs.mkdtempSync(path.join(tmpdir(), "shadcn-tests"))
console.log("TEMP_DIR", TEMP_DIR)
export async function setup() {
await rimraf(TEMP_DIR)
await fs.ensureDir(TEMP_DIR)
}
export async function teardown() {
await rimraf(TEMP_DIR)
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "temp", "fixtures"]
}

View File

@@ -0,0 +1,19 @@
import tsconfigPaths from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
testTimeout: 120000,
hookTimeout: 120000,
globals: true,
environment: "node",
globalSetup: "./src/utils/setup.ts",
maxConcurrency: 4,
isolate: false,
},
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
})

1450
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,6 +8,7 @@ export default defineConfig({
"**/node_modules/**",
"**/fixtures/**",
"**/templates/**",
"**/packages/tests/**",
],
},
plugins: [

View File

@@ -1,3 +1,6 @@
import { defineWorkspace } from "vitest/config"
export default defineWorkspace(["./vitest.config.ts"])
export default defineWorkspace([
"./vitest.config.ts",
"./packages/tests/vitest.config.ts",
])