Compare commits

..

2 Commits

Author SHA1 Message Date
shadcn
319f7f9419 chore: build external 2025-07-14 20:06:47 +04:00
shadcn
e6bc16461a feat: poc 2025-07-14 20:02:02 +04:00
26 changed files with 711 additions and 341 deletions

View File

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

3
apps/v4/.gitignore vendored
View File

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

View File

@@ -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"

View File

@@ -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()
}

View File

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

View File

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

View File

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

View File

@@ -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(
`Im looking at this shadcn/ui documentation: ${url}.
Help me understand how to use it. Be ready to explain concepts, give examples, or help debug based on it.
`
)}`
}
const menuItems = {
markdown: (url: string) => (
<a href={`${url}.mdx`} target="_blank" rel="noopener noreferrer">
<svg strokeLinejoin="round" viewBox="0 0 22 16">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.5 2.25H2.5C1.80964 2.25 1.25 2.80964 1.25 3.5V12.5C1.25 13.1904 1.80964 13.75 2.5 13.75H19.5C20.1904 13.75 20.75 13.1904 20.75 12.5V3.5C20.75 2.80964 20.1904 2.25 19.5 2.25ZM2.5 1C1.11929 1 0 2.11929 0 3.5V12.5C0 13.8807 1.11929 15 2.5 15H19.5C20.8807 15 22 13.8807 22 12.5V3.5C22 2.11929 20.8807 1 19.5 1H2.5ZM3 4.5H4H4.25H4.6899L4.98715 4.82428L7 7.02011L9.01285 4.82428L9.3101 4.5H9.75H10H11V5.5V11.5H9V7.79807L7.73715 9.17572L7 9.97989L6.26285 9.17572L5 7.79807V11.5H3V5.5V4.5ZM15 8V4.5H17V8H19.5L17 10.5L16 11.5L15 10.5L12.5 8H15Z"
fill="currentColor"
/>
</svg>
View as Markdown
</a>
),
v0: (url: string) => (
<a
href={getPromptUrl("https://v0.dev", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 147 70"
className="size-4.5 -translate-x-px"
>
<path d="M56 50.203V14h14v46.156C70 65.593 65.593 70 60.156 70c-2.596 0-5.158-1-7-2.843L0 14h19.797L56 50.203ZM147 56h-14V23.953L100.953 56H133v14H96.687C85.814 70 77 61.186 77 50.312V14h14v32.156L123.156 14H91V0h36.312C138.186 0 147 8.814 147 19.688V56Z" />
</svg>
Open in v0
</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>
)
}

View File

@@ -2,10 +2,10 @@ import { siteConfig } from "@/lib/config"
export function SiteFooter() {
return (
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent 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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,5 @@
# @shadcn/ui
## 2.9.3
### Patch Changes
- [#7837](https://github.com/shadcn-ui/ui/pull/7837) [`20e913d8e1df1acddc7bd4b8328088a25869ba7c`](https://github.com/shadcn-ui/ui/commit/20e913d8e1df1acddc7bd4b8328088a25869ba7c) Thanks [@shadcn](https://github.com/shadcn)! - fix handling of themes
## 2.9.2
### Patch Changes
- [#7833](https://github.com/shadcn-ui/ui/pull/7833) [`d9cdc3f7ae69e571de7dc116effc381ad76685c3`](https://github.com/shadcn-ui/ui/commit/d9cdc3f7ae69e571de7dc116effc381ad76685c3) Thanks [@shadcn](https://github.com/shadcn)! - Revert "fix: handling of shouldOverwriteCssVars"
## 2.9.1
### Patch Changes
- [#7829](https://github.com/shadcn-ui/ui/pull/7829) [`ed5237c231f3b70107131bd7ba517e73b8c9014d`](https://github.com/shadcn-ui/ui/commit/ed5237c231f3b70107131bd7ba517e73b8c9014d) Thanks [@shadcn](https://github.com/shadcn)! - fix handling of shouldOverwriteCssVars
## 2.9.0
### Minor Changes

View File

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

View File

@@ -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)
})
})

View File

@@ -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)
)
}

View File

@@ -1,7 +1,6 @@
import path from "path"
import {
fetchRegistry,
getRegistryItem,
getRegistryParentMap,
getRegistryTypeAliasMap,
registryResolveItemsTree,
@@ -328,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(

47
pnpm-lock.yaml generated
View File

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

View File

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