mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-15 20:01:35 +00:00
Compare commits
2 Commits
shadcn@2.1
...
shadcn/reg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319f7f9419 | ||
|
|
e6bc16461a |
@@ -7,5 +7,5 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["www", "v4", "tests"]
|
||||
"ignore": ["www", "v4"]
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Bash(npm run typecheck:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Create Version PR or Publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
uses: changesets/action@v1.4.1
|
||||
with:
|
||||
commit: "chore(release): version packages"
|
||||
title: "chore(release): version packages"
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -8,9 +8,6 @@ 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:
|
||||
@@ -42,7 +39,4 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build --filter=shadcn
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
3
apps/v4/.gitignore
vendored
3
apps/v4/.gitignore
vendored
@@ -46,3 +46,6 @@ next-env.d.ts
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
|
||||
# Generated data
|
||||
.data/
|
||||
|
||||
@@ -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 = "The Foundation for your Design System"
|
||||
const title = "Build your Component Library"
|
||||
const description =
|
||||
"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."
|
||||
"A set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. 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 className="max-w-4xl">{title}</PageHeaderHeading>
|
||||
<PageHeaderHeading>{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="/docs/components">View Components</Link>
|
||||
<Link href="/blocks">Browse Blocks</Link>
|
||||
</Button>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
@@ -103,17 +102,12 @@ 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="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)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
{neighbours.previous && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="extend-touch-target ml-auto size-8 shadow-none md:size-7"
|
||||
className="extend-touch-target size-8 shadow-none md:size-7"
|
||||
asChild
|
||||
>
|
||||
<Link href={neighbours.previous.url}>
|
||||
@@ -166,7 +160,7 @@ export default async function Page(props: {
|
||||
<MDX components={mdxComponents} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto hidden h-16 w-full max-w-2xl items-center gap-2 px-4 sm:flex md:px-0">
|
||||
<div className="mx-auto flex h-16 w-full max-w-2xl items-center gap-2 px-4 md:px-0">
|
||||
{neighbours.previous && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DataTable<TData, TValue>({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<DataTableToolbar table={table} />
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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()
|
||||
}
|
||||
150
apps/v4/app/api/search/community/route.ts
Normal file
150
apps/v4/app/api/search/community/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import type { Orama } from "@orama/orama"
|
||||
import { create, load, search } from "@orama/orama"
|
||||
import { registryItemSchema } from "shadcn/registry"
|
||||
import { z } from "zod"
|
||||
|
||||
type RegistryItem = z.infer<typeof registryItemSchema>
|
||||
|
||||
const searchSchema = {
|
||||
name: "string",
|
||||
description: "string",
|
||||
type: "string",
|
||||
author: "string",
|
||||
url: "string",
|
||||
registryName: "string",
|
||||
} as const
|
||||
|
||||
let searchDb: Orama<typeof searchSchema> | null = null
|
||||
|
||||
async function getSearchDb() {
|
||||
if (searchDb) return searchDb
|
||||
|
||||
try {
|
||||
const indexPath = path.join(
|
||||
process.cwd(),
|
||||
".data",
|
||||
"external-registries-index.json"
|
||||
)
|
||||
const indexData = await fs.readFile(indexPath, "utf-8")
|
||||
const savedDb = JSON.parse(indexData)
|
||||
|
||||
searchDb = await create({
|
||||
schema: searchSchema,
|
||||
})
|
||||
|
||||
await load(searchDb, savedDb)
|
||||
return searchDb
|
||||
} catch (error) {
|
||||
console.error("Failed to load search index:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get("q") || ""
|
||||
const limit = parseInt(searchParams.get("limit") || "20")
|
||||
const offset = parseInt(searchParams.get("offset") || "0")
|
||||
|
||||
if (!query) {
|
||||
// Return all items if no query
|
||||
const registryPath = path.join(
|
||||
process.cwd(),
|
||||
".data",
|
||||
"external-registries.json"
|
||||
)
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(registryPath, "utf-8")
|
||||
const registry = JSON.parse(data)
|
||||
|
||||
const items = registry.items.slice(offset, offset + limit)
|
||||
return Response.json(
|
||||
{
|
||||
items,
|
||||
total: registry.items.length,
|
||||
hasMore: offset + limit < registry.items.length,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control":
|
||||
"public, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return Response.json({
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Search using Orama
|
||||
const db = await getSearchDb()
|
||||
if (!db) {
|
||||
// No search index - return empty results
|
||||
return Response.json({
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
})
|
||||
}
|
||||
|
||||
const results = await search(db, {
|
||||
term: query,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
console.log(`Search query: "${query}", found ${results.count} results`)
|
||||
|
||||
// Load the full registry to get complete item data
|
||||
const registryPath = path.join(
|
||||
process.cwd(),
|
||||
".data",
|
||||
"external-registries.json"
|
||||
)
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(registryPath, "utf-8")
|
||||
const registry = JSON.parse(data)
|
||||
|
||||
// Map search results to full items
|
||||
const items = results.hits
|
||||
.map((hit) => {
|
||||
return registry.items.find(
|
||||
(item: RegistryItem) => item.name === hit.document.name
|
||||
)
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
items,
|
||||
total: results.count,
|
||||
hasMore: offset + limit < results.count,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control":
|
||||
"public, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return Response.json({
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Search API error:", error)
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||
|
||||
import { META_THEME_COLORS, siteConfig } from "@/lib/config"
|
||||
import { fontVariables } from "@/lib/fonts"
|
||||
@@ -88,16 +89,18 @@ export default function RootLayout({
|
||||
fontVariables
|
||||
)}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<LayoutProvider>
|
||||
<ActiveThemeProvider>
|
||||
{children}
|
||||
<TailwindIndicator />
|
||||
<Toaster position="top-center" />
|
||||
<Analytics />
|
||||
</ActiveThemeProvider>
|
||||
</LayoutProvider>
|
||||
</ThemeProvider>
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider>
|
||||
<LayoutProvider>
|
||||
<ActiveThemeProvider>
|
||||
{children}
|
||||
<TailwindIndicator />
|
||||
<Toaster position="top-center" />
|
||||
<Analytics />
|
||||
</ActiveThemeProvider>
|
||||
</LayoutProvider>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -209,7 +209,7 @@ export function CardsPayments() {
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
236
apps/v4/components/components-community.tsx
Normal file
236
apps/v4/components/components-community.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ArrowUpRightIcon, Loader2Icon, SearchIcon } from "lucide-react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { registryItemSchema } from "shadcn/registry"
|
||||
import { z } from "zod"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import { Input } from "@/registry/new-york-v4/ui/input"
|
||||
import { Skeleton } from "@/registry/new-york-v4/ui/skeleton"
|
||||
|
||||
export function ComponentsCommunitySearch({
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("grid gap-4", className)}>
|
||||
<ComponentsCommunitySearchForm />
|
||||
<ComponentsCommunitySearchResults />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
items: z.infer<typeof registryItemSchema>[]
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
function ComponentsCommunitySearchForm() {
|
||||
const [search, setSearch] = useQueryState("q", {
|
||||
defaultValue: "",
|
||||
throttleMs: 150,
|
||||
})
|
||||
const [isSearching, setIsSearching] = React.useState(false)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
||||
const activeElement = document.activeElement
|
||||
const isInputFocused =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement
|
||||
if (!isInputFocused) {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Search components..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setIsSearching(true)
|
||||
setTimeout(() => setIsSearching(false), 300)
|
||||
}}
|
||||
className="pr-9 pl-9 shadow-none"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2Icon className="text-muted-foreground absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentsCommunityResults({
|
||||
items,
|
||||
}: {
|
||||
items: z.infer<typeof registryItemSchema>[]
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="p-6 text-center text-sm">
|
||||
<p className="text-muted-foreground text-balance">
|
||||
No components found for this search. Try a different search term.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
{items.map((item, index) => (
|
||||
<a
|
||||
key={`${item.type}-${item.name}-${index}`}
|
||||
href={`${item.meta?.url ?? ""}?utm_source=shadcn-ui&utm_medium=referral&utm_campaign=components-community`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 flex h-16 flex-col gap-1 rounded-md p-3 transition-colors outline-none focus-visible:ring-[3px]"
|
||||
title={`${item.name} - ${item.description}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 leading-none font-medium underline-offset-4">
|
||||
{item.name}{" "}
|
||||
{item.meta?.registryName && (
|
||||
<div className="text-muted-foreground ml-auto flex items-center gap-1 text-xs opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
{item.meta.registryName}
|
||||
<ArrowUpRightIcon className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className="text-muted-foreground line-clamp-1 max-w-[80%] text-sm">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentsCommunitySkeleton() {
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="flex h-16 flex-col gap-1 rounded-md p-3">
|
||||
<Skeleton className="h-4 w-32 rounded-md" />
|
||||
<Skeleton className="h-4 w-full max-w-[90%] rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentsCommunitySearchResults() {
|
||||
const [search] = useQueryState("q", { defaultValue: "" })
|
||||
const [items, setItems] = React.useState<
|
||||
z.infer<typeof registryItemSchema>[]
|
||||
>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [hasMore, setHasMore] = React.useState(false)
|
||||
const [offset, setOffset] = React.useState(0)
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null)
|
||||
const searchCacheRef = React.useRef<Map<string, SearchResponse>>(new Map())
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async (query: string, currentOffset = 0) => {
|
||||
const cacheKey = `${query}:${currentOffset}`
|
||||
const cached = searchCacheRef.current.get(cacheKey)
|
||||
if (cached && currentOffset === 0) {
|
||||
setItems(cached.items)
|
||||
setHasMore(cached.hasMore)
|
||||
setOffset(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: "20",
|
||||
offset: currentOffset.toString(),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/search/community?${params}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Search failed")
|
||||
}
|
||||
|
||||
const data: SearchResponse = await response.json()
|
||||
|
||||
searchCacheRef.current.set(cacheKey, data)
|
||||
|
||||
if (currentOffset === 0) {
|
||||
setItems(data.items)
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...data.items])
|
||||
}
|
||||
|
||||
setHasMore(data.hasMore)
|
||||
setOffset(currentOffset)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
console.error("Search error:", error)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
performSearch(search, 0)
|
||||
}, [search, performSearch])
|
||||
|
||||
const loadMore = React.useCallback(() => {
|
||||
if (!loading && hasMore) {
|
||||
performSearch(search, offset + 20)
|
||||
}
|
||||
}, [search, offset, loading, hasMore, performSearch])
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return <ComponentsCommunitySkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComponentsCommunityResults items={items} />
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,156 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { IconCheck, IconChevronDown, IconCopy } from "@tabler/icons-react"
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
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"
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tooltip"
|
||||
|
||||
function getPromptUrl(baseURL: string, url: string) {
|
||||
return `${baseURL}?q=${encodeURIComponent(
|
||||
`I’m 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 }) {
|
||||
export function DocsCopyPage({ page }: { page: 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 (
|
||||
<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 />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shadow-none md:h-7 md:text-[0.8rem]"
|
||||
className="h-8 pl-1.5 md:h-7 [&>svg]:size-3.5"
|
||||
onClick={() => copyToClipboard(page)}
|
||||
>
|
||||
{isCopied ? <IconCheck /> : <IconCopy />}
|
||||
Copy Page
|
||||
{isCopied ? <IconCheck /> : <IconCopy />} Copy Page
|
||||
</Button>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy as Markdown</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = "new-york-v4"
|
||||
const V0_STYLE = "default"
|
||||
|
||||
export function OpenInV0Button({
|
||||
name,
|
||||
|
||||
@@ -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 group-has-[.docs-nav]/body:pb-20 group-has-[.docs-nav]/body:sm:pb-0 dark:bg-transparent">
|
||||
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent 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 px-1 text-center text-xs leading-loose sm:text-sm">
|
||||
<div className="text-muted-foreground w-full text-center text-xs leading-loose sm:text-sm">
|
||||
Built by{" "}
|
||||
<a
|
||||
href={siteConfig.links.twitter}
|
||||
|
||||
12
apps/v4/content/docs/components/community.mdx
Normal file
12
apps/v4/content/docs/components/community.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Community
|
||||
description: Discover components from the community.
|
||||
---
|
||||
|
||||
import { ComponentsCommunitySearch } from "@/components/components-community"
|
||||
|
||||
The following components are created and maintained by the community. They are compatible with shadcn/ui primitives and works with the CLI.
|
||||
|
||||
You will be taken to the external component page for preview, documentation and installation instructions.
|
||||
|
||||
<ComponentsCommunitySearch className="mt-6" />
|
||||
@@ -185,7 +185,7 @@ export function DataTable<TData, TValue>({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -425,7 +425,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
{ // .... }
|
||||
</Table>
|
||||
@@ -499,7 +499,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>{ ... }</Table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -602,7 +602,7 @@ export function DataTable<TData, TValue>({
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>{ ... }</Table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -715,7 +715,7 @@ export function DataTable<TData, TValue>({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>{ ... }</Table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -805,7 +805,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,4 +3,9 @@ title: Components
|
||||
description: Here you can find all the components available in the library. We are working on adding more components.
|
||||
---
|
||||
|
||||
<Callout className="mb-6">
|
||||
Looking for more components? Check out the [Components
|
||||
Community](/docs/components/community) page.
|
||||
</Callout>
|
||||
|
||||
<ComponentsList />
|
||||
|
||||
4
apps/v4/content/docs/components/meta.json
Normal file
4
apps/v4/content/docs/components/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Components",
|
||||
"pages": ["!community", "..."]
|
||||
}
|
||||
@@ -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 components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code.",
|
||||
"A set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. Open Source. Open Code.",
|
||||
links: {
|
||||
twitter: "https://twitter.com/shadcn",
|
||||
github: "https://github.com/shadcn-ui/ui",
|
||||
@@ -33,6 +33,10 @@ export const siteConfig = {
|
||||
href: "/colors",
|
||||
label: "Colors",
|
||||
},
|
||||
{
|
||||
href: "/docs/components/community",
|
||||
label: "Community",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -68,13 +68,10 @@ const nextConfig = {
|
||||
destination: "/view/:name",
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/docs/:path*.mdx",
|
||||
destination: "/llm/:path*",
|
||||
source: "/community",
|
||||
destination: "/docs/components/community",
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 4000",
|
||||
"build": "pnpm --filter=shadcn build && next build",
|
||||
"build": "pnpm --filter=shadcn build && pnpm build:external && next build",
|
||||
"start": "next start --port 4000",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
@@ -14,6 +14,7 @@
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"registry:build": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,json,mdx}\" --cache",
|
||||
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
|
||||
"build:external": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-external-registry.mts",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -23,6 +24,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^8.2.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@orama/orama": "^3.1.11",
|
||||
"@radix-ui/react-accessible-icon": "^1.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.5",
|
||||
@@ -54,7 +56,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.1.11",
|
||||
"@tailwindcss/postcss": "^4.0.1",
|
||||
"@tanstack/react-table": "^8.9.1",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
"change-case": "^5.4.4",
|
||||
@@ -77,6 +79,7 @@
|
||||
"motion": "^12.12.1",
|
||||
"next": "15.3.1",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "^2.4.3",
|
||||
"postcss": "^8.5.1",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.7.0",
|
||||
@@ -86,7 +89,7 @@
|
||||
"recharts": "2.15.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"shadcn": "2.10.0",
|
||||
"shadcn": "2.9.0",
|
||||
"shiki": "^1.10.1",
|
||||
"sonner": "^2.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
@@ -108,7 +111,7 @@
|
||||
"eslint-config-next": "15.3.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"typescript": "^5",
|
||||
"unist-builder": "3.0.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -191,13 +191,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
||||
@@ -257,6 +257,7 @@ export function ChartAreaInteractive() {
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
defaultIndex={isMobile ? -1 : 10}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
|
||||
@@ -233,7 +233,7 @@ export default function DataTableDemo() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
5
apps/v4/registry/registry-external.json
Normal file
5
apps/v4/registry/registry-external.json
Normal file
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"https://api.npoint.io/db04a255782ab866fcd3",
|
||||
"https://api.npoint.io/8183a39e7dad9bb86e78",
|
||||
"https://api.npoint.io/e69a11a4d660bb12c0de"
|
||||
]
|
||||
171
apps/v4/scripts/build-external-registry.mts
Normal file
171
apps/v4/scripts/build-external-registry.mts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { create, insertMultiple, save } from "@orama/orama"
|
||||
import { z } from "zod"
|
||||
|
||||
// Schema for registries.json - just an array of URLs
|
||||
const RegistriesConfigSchema = z.array(z.string().url())
|
||||
|
||||
// Schema for registry items (matching the public schema)
|
||||
const RegistryItemSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum([
|
||||
"registry:lib",
|
||||
"registry:block",
|
||||
"registry:component",
|
||||
"registry:ui",
|
||||
"registry:hook",
|
||||
"registry:theme",
|
||||
"registry:page",
|
||||
"registry:file",
|
||||
"registry:style",
|
||||
"registry:item",
|
||||
]),
|
||||
description: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
devDependencies: z.array(z.string()).optional(),
|
||||
registryDependencies: z.array(z.string()).optional(),
|
||||
files: z.array(z.any()).optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
meta: z.record(z.any()).optional(),
|
||||
})
|
||||
|
||||
const RegistrySchema = z.object({
|
||||
name: z.string(),
|
||||
homepage: z.string(),
|
||||
items: z.array(RegistryItemSchema),
|
||||
})
|
||||
|
||||
type Registry = z.infer<typeof RegistrySchema>
|
||||
type RegistryItem = z.infer<typeof RegistryItemSchema>
|
||||
|
||||
interface ProcessedRegistry {
|
||||
url: string
|
||||
data: Registry
|
||||
items: RegistryItem[]
|
||||
fetchedAt: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function fetchRegistry(url: string): Promise<ProcessedRegistry> {
|
||||
console.log(`📥 Fetching registry from ${url}`)
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const validated = RegistrySchema.parse(data)
|
||||
|
||||
// Items are already in the correct format
|
||||
const items = validated.items
|
||||
|
||||
console.log(`✅ Successfully fetched ${validated.items.length} items from ${validated.name}`)
|
||||
|
||||
return {
|
||||
url,
|
||||
data: validated,
|
||||
items: items,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to fetch ${url}:`, error)
|
||||
return {
|
||||
url,
|
||||
data: { name: "Unknown", homepage: url, items: [] },
|
||||
items: [],
|
||||
fetchedAt: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExternalRegistry() {
|
||||
console.log("🔨 Building external registry index...")
|
||||
|
||||
// Read registries config
|
||||
const configPath = path.join(process.cwd(), "registry", "registry-external.json")
|
||||
const configContent = await fs.readFile(configPath, "utf-8")
|
||||
const config = RegistriesConfigSchema.parse(JSON.parse(configContent))
|
||||
|
||||
// Output will go to content directory
|
||||
|
||||
// Fetch all registries
|
||||
const results = await Promise.all(
|
||||
config.map(url => fetchRegistry(url))
|
||||
)
|
||||
|
||||
// Combine all items from all registries, adding registry name to each item
|
||||
const allItems = results.flatMap(r =>
|
||||
r.items.map(item => ({
|
||||
...item,
|
||||
meta: {
|
||||
...item.meta,
|
||||
registryName: r.data.name,
|
||||
registryHomepage: r.data.homepage,
|
||||
}
|
||||
}))
|
||||
)
|
||||
|
||||
// Create a registry following the standard schema
|
||||
const registry = {
|
||||
name: "External Registries",
|
||||
homepage: "https://ui.shadcn.com/docs/components",
|
||||
items: allItems,
|
||||
}
|
||||
|
||||
// Create data directory
|
||||
const dataDir = path.join(process.cwd(), ".data")
|
||||
await fs.mkdir(dataDir, { recursive: true })
|
||||
|
||||
// Write registry to data directory
|
||||
const outputPath = path.join(dataDir, "external-registries.json")
|
||||
await fs.writeFile(outputPath, JSON.stringify(registry, null, 2))
|
||||
|
||||
// Create search index
|
||||
console.log("🔍 Building search index...")
|
||||
const searchDb = await create({
|
||||
schema: {
|
||||
name: 'string',
|
||||
description: 'string',
|
||||
type: 'string',
|
||||
author: 'string',
|
||||
url: 'string',
|
||||
registryName: 'string',
|
||||
},
|
||||
components: {
|
||||
tokenizer: {
|
||||
stemming: false, // Disable stemming for faster indexing
|
||||
stopWords: false, // Disable stop words for component names
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Prepare items for indexing
|
||||
const searchItems = allItems.map(item => ({
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
type: item.type,
|
||||
author: item.author || '',
|
||||
url: item.meta?.url || '',
|
||||
registryName: item.meta?.registryName || '',
|
||||
}))
|
||||
|
||||
await insertMultiple(searchDb, searchItems)
|
||||
|
||||
// Save search index
|
||||
const indexPath = path.join(dataDir, "external-registries-index.json")
|
||||
const index = await save(searchDb)
|
||||
await fs.writeFile(indexPath, JSON.stringify(index))
|
||||
|
||||
console.log(`✨ External registry built successfully!`)
|
||||
console.log(`📊 Total registries: ${results.length}`)
|
||||
console.log(`📦 Total items: ${allItems.length}`)
|
||||
console.log(`📍 Registry saved to: ${outputPath}`)
|
||||
console.log(`🔍 Search index saved to: ${indexPath}`)
|
||||
}
|
||||
|
||||
buildExternalRegistry().catch(console.error)
|
||||
@@ -70,7 +70,7 @@ export function DataTable<TData, TValue>({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DataTableToolbar table={table} />
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -239,7 +239,7 @@ export function CardsDataTable() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -185,7 +185,7 @@ export function DataTable<TData, TValue>({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -425,7 +425,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
{ // .... }
|
||||
</Table>
|
||||
@@ -499,7 +499,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>{ ... }</Table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -602,7 +602,7 @@ export function DataTable<TData, TValue>({
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>{ ... }</Table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -715,7 +715,7 @@ export function DataTable<TData, TValue>({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>{ ... }</Table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -805,7 +805,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"react-resizable-panels": "^2.0.22",
|
||||
"react-wrap-balancer": "^0.4.1",
|
||||
"recharts": "2.12.7",
|
||||
"shadcn": "2.10.0",
|
||||
"shadcn": "2.9.0",
|
||||
"sharp": "^0.32.6",
|
||||
"sonner": "^1.2.3",
|
||||
"swr": "2.2.6-beta.3",
|
||||
|
||||
@@ -233,7 +233,7 @@ export default function DataTableDemo() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -233,7 +233,7 @@ export default function DataTableDemo() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"www:build": "pnpm --filter=www build",
|
||||
"v4:dev": "pnpm --filter=v4 dev",
|
||||
"v4:build": "pnpm --filter=v4 build",
|
||||
"build:external": "pnpm --filter=v4 build:external",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"preview": "turbo run preview",
|
||||
@@ -46,8 +47,7 @@
|
||||
"release": "changeset version",
|
||||
"pub:beta": "cd packages/shadcn && pnpm pub:beta",
|
||||
"pub:release": "cd packages/shadcn && pnpm pub:release",
|
||||
"test:dev": "turbo run test --filter=!shadcn-ui --force",
|
||||
"test": "start-server-and-test v4:dev http://localhost:4000 test:dev"
|
||||
"test": "turbo run test --filter=!shadcn-ui --force"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"dependencies": {
|
||||
@@ -86,7 +86,6 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
# @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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shadcn",
|
||||
"version": "2.10.0",
|
||||
"version": "2.9.0",
|
||||
"description": "Add components to your apps.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -476,12 +476,7 @@ export async function registryResolveItemsTree(
|
||||
}
|
||||
})
|
||||
|
||||
let envVars = {}
|
||||
payload.forEach((item) => {
|
||||
envVars = deepmerge(envVars, item.envVars ?? {})
|
||||
})
|
||||
|
||||
const parsed = registryResolvedItemsTreeSchema.parse({
|
||||
return registryResolvedItemsTreeSchema.parse({
|
||||
dependencies: deepmerge.all(
|
||||
payload.map((item) => item.dependencies ?? [])
|
||||
),
|
||||
@@ -494,12 +489,6 @@ export async function registryResolveItemsTree(
|
||||
css,
|
||||
docs,
|
||||
})
|
||||
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
parsed.envVars = envVars
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return null
|
||||
|
||||
@@ -65,8 +65,6 @@ 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(),
|
||||
@@ -82,7 +80,6 @@ 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(),
|
||||
@@ -130,6 +127,5 @@ export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
|
||||
tailwind: true,
|
||||
cssVars: true,
|
||||
css: true,
|
||||
envVars: true,
|
||||
docs: true,
|
||||
})
|
||||
|
||||
@@ -137,32 +137,18 @@ describe("isLocalFile", () => {
|
||||
})
|
||||
|
||||
describe("isUniversalRegistryItem", () => {
|
||||
it("should return true when all files have targets with registry:file type", () => {
|
||||
it("should return true when all files have targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: "src/file1.ts",
|
||||
type: "registry:file" as const,
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{
|
||||
path: "file2.ts",
|
||||
target: "src/utils/file2.ts",
|
||||
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,
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -175,27 +161,9 @@ describe("isUniversalRegistryItem", () => {
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: "src/file1.ts",
|
||||
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
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{ path: "file2.ts", target: "", type: "registry:lib" as const },
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
@@ -204,8 +172,8 @@ describe("isUniversalRegistryItem", () => {
|
||||
it("should return false when no files have targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{ path: "file1.ts", target: "", type: "registry:file" as const },
|
||||
{ path: "file2.ts", target: "", type: "registry:file" as const },
|
||||
{ path: "file1.ts", target: "", type: "registry:lib" as const },
|
||||
{ path: "file2.ts", target: "", type: "registry:lib" as const },
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
@@ -237,7 +205,7 @@ describe("isUniversalRegistryItem", () => {
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: null as any,
|
||||
type: "registry:file" as const,
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -246,96 +214,60 @@ describe("isUniversalRegistryItem", () => {
|
||||
|
||||
it("should return false when target is undefined", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
type: "registry:file" as const,
|
||||
target: undefined as any,
|
||||
},
|
||||
],
|
||||
files: [{ path: "file1.ts", type: "registry:lib" as const }],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files have registry:component type even with targets", () => {
|
||||
it("should handle mixed file types correctly", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "component.tsx",
|
||||
target: "components/ui/component.tsx",
|
||||
type: "registry:component" as const,
|
||||
type: "registry:ui" 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when all targets are non-empty strings for registry:file", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{ 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
|
||||
{
|
||||
path: "hook.ts",
|
||||
target: "hooks/use-something.ts",
|
||||
type: "registry:hook" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle real-world example with path traversal attempts for registry:file", () => {
|
||||
it("should return true when all targets are non-empty strings", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{ path: "file1.ts", target: " ", type: "registry:lib" as const }, // whitespace is truthy
|
||||
{ path: "file2.ts", target: "0", type: "registry:lib" as const }, // "0" is truthy
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle real-world example with path traversal attempts", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "malicious.ts",
|
||||
target: "../../../etc/passwd",
|
||||
type: "registry:file" as const,
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{
|
||||
path: "normal.ts",
|
||||
target: "src/normal.ts",
|
||||
type: "registry:file" as const,
|
||||
type: "registry:lib" 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,9 +259,7 @@ export function isLocalFile(path: string) {
|
||||
|
||||
/**
|
||||
* Check if a registry item is universal (framework-agnostic).
|
||||
* A universal registry item must have all files with:
|
||||
* 1. Explicit targets
|
||||
* 2. Type "registry:file"
|
||||
* A universal registry item has all files with explicit targets.
|
||||
* It can be installed without framework detection or components.json.
|
||||
*/
|
||||
export function isUniversalRegistryItem(
|
||||
@@ -272,8 +270,6 @@ export function isUniversalRegistryItem(
|
||||
): boolean {
|
||||
return (
|
||||
!!registryItem?.files?.length &&
|
||||
registryItem.files.every(
|
||||
(file) => !!file.target && file.type === "registry:file"
|
||||
)
|
||||
registryItem.files.every((file) => !!file.target)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from "path"
|
||||
import {
|
||||
fetchRegistry,
|
||||
getRegistryItem,
|
||||
getRegistryParentMap,
|
||||
getRegistryTypeAliasMap,
|
||||
registryResolveItemsTree,
|
||||
@@ -27,7 +26,6 @@ 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"
|
||||
@@ -117,10 +115,6 @@ async function addProjectComponents(
|
||||
silent: options.silent,
|
||||
})
|
||||
|
||||
await updateEnvVars(tree.envVars, config, {
|
||||
silent: options.silent,
|
||||
})
|
||||
|
||||
await updateDependencies(tree.dependencies, tree.devDependencies, config, {
|
||||
silent: options.silent,
|
||||
})
|
||||
@@ -237,14 +231,7 @@ async function addWorkspaceComponents(
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Update environment variables
|
||||
if (component.envVars) {
|
||||
await updateEnvVars(component.envVars, targetConfig, {
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Update dependencies.
|
||||
// 4. Update dependencies.
|
||||
await updateDependencies(
|
||||
component.dependencies,
|
||||
component.devDependencies,
|
||||
@@ -254,7 +241,7 @@ async function addWorkspaceComponents(
|
||||
}
|
||||
)
|
||||
|
||||
// 6. Update files.
|
||||
// 5. Update files.
|
||||
const files = await updateFiles(component.files, targetConfig, {
|
||||
overwrite: options.overwrite,
|
||||
silent: true,
|
||||
@@ -340,9 +327,8 @@ async function shouldOverwriteCssVars(
|
||||
components: z.infer<typeof registryItemSchema>["name"][],
|
||||
config: z.infer<typeof configSchema>
|
||||
) {
|
||||
let result = await Promise.all(
|
||||
components.map((component) => getRegistryItem(component, config.style))
|
||||
)
|
||||
let registryItems = await resolveRegistryItems(components, config)
|
||||
let result = await fetchRegistry(registryItems)
|
||||
const payload = z.array(registryItemSchema).parse(result)
|
||||
|
||||
return payload.some(
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,6 @@ 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"
|
||||
@@ -66,8 +59,6 @@ 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) {
|
||||
@@ -96,40 +87,29 @@ export async function updateFiles(
|
||||
)
|
||||
}
|
||||
|
||||
if (isEnvFile(filePath) && !existsSync(filePath)) {
|
||||
const alternativeEnvFile = findExistingEnvFile(targetDir)
|
||||
if (alternativeEnvFile) {
|
||||
filePath = alternativeEnvFile
|
||||
}
|
||||
}
|
||||
|
||||
const existingFile = existsSync(filePath)
|
||||
|
||||
// Run our transformers.
|
||||
// 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,
|
||||
]
|
||||
)
|
||||
const 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.
|
||||
// Exception: Don't skip .env files as we merge content instead of replacing
|
||||
if (existingFile && !isEnvFile(filePath)) {
|
||||
if (existingFile) {
|
||||
const existingFileContent = await fs.readFile(filePath, "utf-8")
|
||||
const [normalizedExisting, normalizedNew] = await Promise.all([
|
||||
getNormalizedFileContent(existingFileContent),
|
||||
@@ -141,8 +121,7 @@ export async function updateFiles(
|
||||
}
|
||||
}
|
||||
|
||||
// Skip overwrite prompt for .env files - we'll handle them specially
|
||||
if (existingFile && !options.overwrite && !isEnvFile(filePath)) {
|
||||
if (existingFile && !options.overwrite) {
|
||||
filesCreatedSpinner.stop()
|
||||
if (options.rootSpinner) {
|
||||
options.rootSpinner.stop()
|
||||
@@ -174,36 +153,10 @@ 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")
|
||||
|
||||
// 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))
|
||||
}
|
||||
existingFile
|
||||
? filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
|
||||
: filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath))
|
||||
}
|
||||
|
||||
const allFiles = [...filesCreated, ...filesUpdated, ...filesSkipped]
|
||||
@@ -272,17 +225,6 @@ 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()
|
||||
}
|
||||
@@ -455,7 +397,7 @@ async function resolveImports(filePaths: string[], config: Config) {
|
||||
compilerOptions: {},
|
||||
})
|
||||
const projectInfo = await getProjectInfo(config.resolvedPaths.cwd)
|
||||
const tsConfig = loadConfig(config.resolvedPaths.cwd)
|
||||
const tsConfig = await loadConfig(config.resolvedPaths.cwd)
|
||||
const updatedFiles = []
|
||||
|
||||
if (!projectInfo || tsConfig.resultType === "failed") {
|
||||
|
||||
201
packages/shadcn/test/commands/init.test.ts
Normal file
201
packages/shadcn/test/commands/init.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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()
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { afterAll, afterEach, describe, expect, test, vi } from "vitest"
|
||||
|
||||
@@ -16,12 +16,9 @@ vi.mock("fs/promises", async () => {
|
||||
const actual = (await vi.importActual(
|
||||
"fs/promises"
|
||||
)) as typeof import("fs/promises")
|
||||
|
||||
return {
|
||||
...actual,
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockImplementation(actual.readFile),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,21 +26,15 @@ 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().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -820,346 +811,6 @@ 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", () => {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
fixtures/
|
||||
temp/
|
||||
3
packages/tests/.gitignore
vendored
3
packages/tests/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
temp/
|
||||
*.log
|
||||
.cache/
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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)
|
||||
})
|
||||
})
|
||||
```
|
||||
BIN
packages/tests/fixtures/next-app/app/favicon.ico
vendored
BIN
packages/tests/fixtures/next-app/app/favicon.ico
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
22
packages/tests/fixtures/next-app/app/layout.tsx
vendored
22
packages/tests/fixtures/next-app/app/layout.tsx
vendored
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
body {
|
||||
background-color: red;
|
||||
}
|
||||
113
packages/tests/fixtures/next-app/app/page.tsx
vendored
113
packages/tests/fixtures/next-app/app/page.tsx
vendored
@@ -1,113 +0,0 @@
|
||||
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
|
||||
<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">
|
||||
->
|
||||
</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">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Learn about Next.js in an interactive course with 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">
|
||||
->
|
||||
</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">
|
||||
->
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
32
packages/tests/fixtures/next-app/package.json
vendored
32
packages/tests/fixtures/next-app/package.json
vendored
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
27
packages/tests/fixtures/next-app/tsconfig.json
vendored
27
packages/tests/fixtures/next-app/tsconfig.json
vendored
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$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!')"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$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\""
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$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%)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
packages/tests/fixtures/vite-app/index.html
vendored
13
packages/tests/fixtures/vite-app/index.html
vendored
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
37
packages/tests/fixtures/vite-app/package.json
vendored
37
packages/tests/fixtures/vite-app/package.json
vendored
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
42
packages/tests/fixtures/vite-app/src/App.css
vendored
42
packages/tests/fixtures/vite-app/src/App.css
vendored
@@ -1,42 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
36
packages/tests/fixtures/vite-app/src/App.tsx
vendored
36
packages/tests/fixtures/vite-app/src/App.tsx
vendored
@@ -1,36 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
11
packages/tests/fixtures/vite-app/src/main.tsx
vendored
11
packages/tests/fixtures/vite-app/src/main.tsx
vendored
@@ -1,11 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
13
packages/tests/fixtures/vite-app/tsconfig.json
vendored
13
packages/tests/fixtures/vite-app/tsconfig.json
vendored
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#custom/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
14
packages/tests/fixtures/vite-app/vite.config.ts
vendored
14
packages/tests/fixtures/vite-app/vite.config.ts
vendored
@@ -1,14 +0,0 @@
|
||||
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"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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
|
||||
"
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
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"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
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};`)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/* 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)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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
1450
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,10 @@
|
||||
"registry:build": {
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"build:external": {
|
||||
"cache": false,
|
||||
"outputs": [".data/external-registries.json", ".data/external-registries-index.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export default defineConfig({
|
||||
"**/node_modules/**",
|
||||
"**/fixtures/**",
|
||||
"**/templates/**",
|
||||
"**/packages/tests/**",
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { defineWorkspace } from "vitest/config"
|
||||
|
||||
export default defineWorkspace([
|
||||
"./vitest.config.ts",
|
||||
"./packages/tests/vitest.config.ts",
|
||||
])
|
||||
export default defineWorkspace(["./vitest.config.ts"])
|
||||
|
||||
Reference in New Issue
Block a user