mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-19 13:51:34 +00:00
Compare commits
23 Commits
shadcn@2.8
...
shadcn@2.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e04567b07 | ||
|
|
6f63b04d28 | ||
|
|
e38228b574 | ||
|
|
8807103586 | ||
|
|
3424ab709e | ||
|
|
4a86a55cac | ||
|
|
2926574d0e | ||
|
|
20e913d8e1 | ||
|
|
3433aaffaa | ||
|
|
d9cdc3f7ae | ||
|
|
e75e7b3866 | ||
|
|
ed5237c231 | ||
|
|
f85ca066dc | ||
|
|
54e66d4450 | ||
|
|
6c341c16ae | ||
|
|
06d03d64f4 | ||
|
|
6407a3b330 | ||
|
|
96b15f6090 | ||
|
|
2fe9cf6d26 | ||
|
|
728cb4cfa5 | ||
|
|
db93787712 | ||
|
|
1cdd6c1645 | ||
|
|
4983c6e1f4 |
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Bash(npm run typecheck:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { findNeighbour } from "fumadocs-core/server"
|
||||
|
||||
import { source } from "@/lib/source"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { DocsCopyPage } from "@/components/docs-copy-page"
|
||||
import { DocsTableOfContents } from "@/components/docs-toc"
|
||||
import { OpenInV0Cta } from "@/components/open-in-v0-cta"
|
||||
import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||
@@ -102,12 +103,17 @@ export default async function Page(props: {
|
||||
<h1 className="scroll-m-20 text-4xl font-semibold tracking-tight sm:text-3xl xl:text-4xl">
|
||||
{doc.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<div className="docs-nav bg-background/80 border-border/50 fixed inset-x-0 bottom-0 isolate z-50 flex items-center gap-2 border-t px-6 py-4 backdrop-blur-sm sm:static sm:z-0 sm:border-t-0 sm:bg-transparent sm:px-0 sm:pt-1.5 sm:backdrop-blur-none">
|
||||
<DocsCopyPage
|
||||
// @ts-expect-error - revisit fumadocs types.
|
||||
page={doc.content}
|
||||
url={absoluteUrl(page.url)}
|
||||
/>
|
||||
{neighbours.previous && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="extend-touch-target size-8 shadow-none md:size-7"
|
||||
className="extend-touch-target ml-auto size-8 shadow-none md:size-7"
|
||||
asChild
|
||||
>
|
||||
<Link href={neighbours.previous.url}>
|
||||
@@ -160,7 +166,7 @@ export default async function Page(props: {
|
||||
<MDX components={mdxComponents} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex h-16 w-full max-w-2xl items-center gap-2 px-4 md:px-0">
|
||||
<div className="mx-auto hidden h-16 w-full max-w-2xl items-center gap-2 px-4 sm:flex md:px-0">
|
||||
{neighbours.previous && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
29
apps/v4/app/(app)/llm/[[...slug]]/route.ts
Normal file
29
apps/v4/app/(app)/llm/[[...slug]]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { NextResponse, type NextRequest } from "next/server"
|
||||
|
||||
import { source } from "@/lib/source"
|
||||
|
||||
export const revalidate = false
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
) {
|
||||
const slug = (await params).slug
|
||||
const page = source.getPage(slug)
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// @ts-expect-error - revisit fumadocs types.
|
||||
return new NextResponse(page.data.content, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.generateParams()
|
||||
}
|
||||
@@ -36,11 +36,13 @@ export function CommandMenu({
|
||||
tree,
|
||||
colors,
|
||||
blocks,
|
||||
navItems,
|
||||
...props
|
||||
}: DialogProps & {
|
||||
tree: typeof source.pageTree
|
||||
colors: ColorPalette[]
|
||||
blocks?: { name: string; description: string; categories: string[] }[]
|
||||
navItems?: { href: string; label: string }[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const isMac = useIsMac()
|
||||
@@ -162,12 +164,45 @@ export function CommandMenu({
|
||||
<DialogTitle>Search documentation...</DialogTitle>
|
||||
<DialogDescription>Search for a command to run...</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:bg-input/50 **:data-[slot=command-input-wrapper]:border-input rounded-none bg-transparent **:data-[slot=command-input]:!h-9 **:data-[slot=command-input]:py-0 **:data-[slot=command-input-wrapper]:mb-0 **:data-[slot=command-input-wrapper]:!h-9 **:data-[slot=command-input-wrapper]:rounded-md **:data-[slot=command-input-wrapper]:border">
|
||||
<Command
|
||||
className="**:data-[slot=command-input-wrapper]:bg-input/50 **:data-[slot=command-input-wrapper]:border-input rounded-none bg-transparent **:data-[slot=command-input]:!h-9 **:data-[slot=command-input]:py-0 **:data-[slot=command-input-wrapper]:mb-0 **:data-[slot=command-input-wrapper]:!h-9 **:data-[slot=command-input-wrapper]:rounded-md **:data-[slot=command-input-wrapper]:border"
|
||||
filter={(value, search, keywords) => {
|
||||
const extendValue = value + " " + (keywords?.join(" ") || "")
|
||||
if (extendValue.toLowerCase().includes(search.toLowerCase())) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder="Search documentation..." />
|
||||
<CommandList className="no-scrollbar min-h-80 scroll-pt-2 scroll-pb-1.5">
|
||||
<CommandEmpty className="text-muted-foreground py-12 text-center text-sm">
|
||||
No results found.
|
||||
</CommandEmpty>
|
||||
{navItems && navItems.length > 0 && (
|
||||
<CommandGroup
|
||||
heading="Pages"
|
||||
className="!p-0 [&_[cmdk-group-heading]]:scroll-mt-16 [&_[cmdk-group-heading]]:!p-3 [&_[cmdk-group-heading]]:!pb-1"
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<CommandMenuItem
|
||||
key={item.href}
|
||||
value={`Navigation ${item.label}`}
|
||||
keywords={["nav", "navigation", item.label.toLowerCase()]}
|
||||
onHighlight={() => {
|
||||
setSelectedType("page")
|
||||
setCopyPayload("")
|
||||
}}
|
||||
onSelect={() => {
|
||||
runCommand(() => router.push(item.href))
|
||||
}}
|
||||
>
|
||||
<IconArrowRight />
|
||||
{item.label}
|
||||
</CommandMenuItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{tree.children.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.$id}
|
||||
|
||||
@@ -1,33 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import { IconCheck, IconChevronDown, IconCopy } from "@tabler/icons-react"
|
||||
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tooltip"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/registry/new-york-v4/ui/dropdown-menu"
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/registry/new-york-v4/ui/popover"
|
||||
import { Separator } from "@/registry/new-york-v4/ui/separator"
|
||||
|
||||
export function DocsCopyPage({ page }: { page: string }) {
|
||||
function getPromptUrl(baseURL: string, url: string) {
|
||||
return `${baseURL}?q=${encodeURIComponent(
|
||||
`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>
|
||||
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 }) {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard()
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="peer -ml-0.5 size-8 shadow-none md:size-7 md:text-[0.8rem]"
|
||||
>
|
||||
<IconChevronDown className="rotate-180 sm:rotate-0" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Popover>
|
||||
<div className="bg-secondary group/buttons relative flex rounded-lg *:[[data-slot=button]]:focus-visible:relative *:[[data-slot=button]]:focus-visible:z-10">
|
||||
<PopoverAnchor />
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 pl-1.5 md:h-7 [&>svg]:size-3.5"
|
||||
className="h-8 shadow-none md:h-7 md:text-[0.8rem]"
|
||||
onClick={() => copyToClipboard(page)}
|
||||
>
|
||||
{isCopied ? <IconCheck /> : <IconCopy />} Copy Page
|
||||
{isCopied ? <IconCheck /> : <IconCopy />}
|
||||
Copy Page
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy as Markdown</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="hidden sm:flex">
|
||||
{trigger}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="shadow-none">
|
||||
{Object.entries(menuItems).map(([key, value]) => (
|
||||
<DropdownMenuItem key={key} asChild>
|
||||
{value(url)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="!bg-foreground/10 absolute top-0 right-8 z-0 !h-8 peer-focus-visible:opacity-0 sm:right-7 sm:!h-7"
|
||||
/>
|
||||
<PopoverTrigger asChild className="flex sm:hidden">
|
||||
{trigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="bg-background/70 dark:bg-background/60 w-52 !origin-center rounded-lg p-1 shadow-sm backdrop-blur-sm"
|
||||
align="start"
|
||||
>
|
||||
{Object.entries(menuItems).map(([key, value]) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
asChild
|
||||
key={key}
|
||||
className="*:[svg]:text-muted-foreground w-full justify-start text-base font-normal"
|
||||
>
|
||||
{value(url)}
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { siteConfig } from "@/lib/config"
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent dark:bg-transparent">
|
||||
<footer className="group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent group-has-[.docs-nav]/body:pb-20 group-has-[.docs-nav]/body:sm:pb-0 dark:bg-transparent">
|
||||
<div className="container-wrapper px-4 xl:px-6">
|
||||
<div className="flex h-(--footer-height) items-center justify-between">
|
||||
<div className="text-muted-foreground w-full text-center text-xs leading-loose sm:text-sm">
|
||||
<div className="text-muted-foreground w-full px-1 text-center text-xs leading-loose sm:text-sm">
|
||||
Built by{" "}
|
||||
<a
|
||||
href={siteConfig.links.twitter}
|
||||
|
||||
@@ -41,7 +41,11 @@ export function SiteHeader() {
|
||||
<MainNav items={siteConfig.navItems} className="hidden lg:flex" />
|
||||
<div className="ml-auto flex items-center gap-2 md:flex-1 md:justify-end">
|
||||
<div className="hidden w-full flex-1 md:flex md:w-auto md:flex-none">
|
||||
<CommandMenu tree={pageTree} colors={colors} />
|
||||
<CommandMenu
|
||||
tree={pageTree}
|
||||
colors={colors}
|
||||
navItems={siteConfig.navItems}
|
||||
/>
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
|
||||
@@ -4,6 +4,33 @@ description: Latest updates and announcements.
|
||||
toc: false
|
||||
---
|
||||
|
||||
## July 2025 - Universal Registry Items
|
||||
|
||||
We've added support for universal registry items. This allows you to create registry items that can be distributed to any project i.e. no framework, no components.json, no tailwind, no react required.
|
||||
|
||||
This new registry item type unlocks a lot of new workflows. You can now distribute code, config, rules, docs, anything to any code project.
|
||||
|
||||
See the [docs](/docs/registry/examples) for more details and examples.
|
||||
|
||||
## July 2025 - Local File Support
|
||||
|
||||
The shadcn CLI now supports local files. Initialize projects and add components, themes, hooks, utils and more from local JSON files.
|
||||
|
||||
```bash
|
||||
# Initialize a project from a local file
|
||||
npx shadcn init ./template.json
|
||||
|
||||
# Add a component from a local file
|
||||
npx shadcn add ./block.json
|
||||
```
|
||||
|
||||
This feature enables powerful new workflows:
|
||||
|
||||
- **Zero setup** - No remote registries required
|
||||
- **Faster development** - Test registry items locally before publishing
|
||||
- **Enhanced workflow for agents and MCP** - Generate and run registry items locally
|
||||
- **Private components** - Keep proprietary components local and private.
|
||||
|
||||
## June 2025 - `radix-ui`
|
||||
|
||||
We've added a new command to migrate to the new `radix-ui` package. This command will replace all `@radix-ui/react-*` imports with `radix-ui`.
|
||||
|
||||
@@ -4,9 +4,15 @@ description: Beautiful charts. Built using Recharts. Copy and paste into your ap
|
||||
component: true
|
||||
---
|
||||
|
||||
<Callout>
|
||||
|
||||
**Note:** We're working on upgrading to Recharts v3. In the meantime, if you'd like to start testing v3, see the code in the comment [here](https://github.com/shadcn-ui/ui/issues/7669#issuecomment-2998299159). We'll have an official release soon.
|
||||
|
||||
</Callout>
|
||||
|
||||
<ComponentPreview
|
||||
name="chart-bar-interactive"
|
||||
className="theme-blue -mt-4 [&_.preview]:p-0 [&_.preview]:lg:min-h-[404px] [&_.preview>div]:w-full [&_.preview>div]:border-none [&_.preview>div]:shadow-none"
|
||||
className="theme-blue [&_.preview]:h-auto [&_.preview]:p-0 [&_.preview]:lg:min-h-[404px] [&_.preview>div]:w-full [&_.preview>div]:border-none [&_.preview>div]:shadow-none"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
|
||||
@@ -368,3 +368,70 @@ Note: you need to define both `@keyframes` in css and `theme` in cssVars to use
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Universal Items
|
||||
|
||||
As of `2.9.0`, you can create universal items that can be installed without framework detection or components.json.
|
||||
|
||||
To make an item universal i.e framework agnostic, all the files in the item must have an explicit target.
|
||||
|
||||
Here's an example of a registry item that installs custom Cursor rules for _python_:
|
||||
|
||||
```json title=".cursor/rules/custom-python.mdc" showLineNumbers {9}
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "python-rules",
|
||||
"type": "registry:item",
|
||||
"files": [
|
||||
{
|
||||
"path": "/path/to/your/registry/default/custom-python.mdc",
|
||||
"type": "registry:file",
|
||||
"target": "~/.cursor/rules/custom-python.mdc",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Here's another example for installation custom ESLint config:
|
||||
|
||||
```json title=".eslintrc.json" showLineNumbers {9}
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "my-eslint-config",
|
||||
"type": "registry:item",
|
||||
"files": [
|
||||
{
|
||||
"path": "/path/to/your/registry/default/custom-eslint.json",
|
||||
"type": "registry:file",
|
||||
"target": "~/.eslintrc.json",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You can also have a universal item that installs multiple files:
|
||||
|
||||
```json title="my-custom-starter-template.json" showLineNumbers {9}
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "my-custom-start-template",
|
||||
"type": "registry:item",
|
||||
dependencies: ["better-auth"]
|
||||
"files": [
|
||||
{
|
||||
"path": "/path/to/file-01.json",
|
||||
"type": "registry:file",
|
||||
"target": "~/file-01.json",
|
||||
"content": "..."
|
||||
},
|
||||
{
|
||||
"path": "/path/to/file-02.vue",
|
||||
"type": "registry:file",
|
||||
"target": "~/pages/file-02.vue",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
---
|
||||
title: Registry
|
||||
description: Run your own component registry.
|
||||
description: Run your own code registry.
|
||||
---
|
||||
|
||||
<Callout>
|
||||
**Note:** This feature is currently experimental. Help us improve it by
|
||||
testing it out and sending feedback. If you have any questions, please [reach
|
||||
out to us](https://github.com/shadcn-ui/ui/discussions).
|
||||
</Callout>
|
||||
You can use the `shadcn` CLI to run your own code registry. Running your own registry allows you to distribute your custom components, hooks, pages, config, rules and other files to any project.
|
||||
|
||||
You can use the `shadcn` CLI to run your own component registry. Running your own registry allows you to distribute your custom components, hooks, pages, and other files to any React project.
|
||||
<Callout>
|
||||
**Note:** The registry works with any project type and any framework, and is
|
||||
not limited to React.
|
||||
</Callout>
|
||||
|
||||
<figure className="flex flex-col gap-4">
|
||||
<Image
|
||||
@@ -27,12 +26,10 @@ You can use the `shadcn` CLI to run your own component registry. Running your ow
|
||||
className="mt-6 hidden w-full overflow-hidden rounded-lg border shadow-sm dark:block"
|
||||
/>
|
||||
<figcaption className="text-center text-sm text-gray-500">
|
||||
Distribute code to any React project.
|
||||
A distribution system for code
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
Registry items are automatically compatible with the `shadcn` CLI and `Open in v0`.
|
||||
|
||||
## Requirements
|
||||
|
||||
You are free to design and host your custom registry as you see fit. The only requirement is that your registry items must be valid JSON files that conform to the [registry-item schema specification](/docs/registry/registry-item-json).
|
||||
|
||||
@@ -107,6 +107,7 @@ The following types are supported:
|
||||
| `registry:file` | Use for miscellaneous files. |
|
||||
| `registry:style` | Use for registry styles. eg. `new-york` |
|
||||
| `registry:theme` | Use for themes. |
|
||||
| `registry:item` | Use for universal registry items. |
|
||||
|
||||
### author
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ const nextConfig = {
|
||||
},
|
||||
]
|
||||
},
|
||||
rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/docs/:path*.mdx",
|
||||
destination: "/llm/:path*",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
const withMDX = createMDX({})
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"recharts": "2.15.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"shadcn": "2.8.0",
|
||||
"shadcn": "2.9.3",
|
||||
"shiki": "^1.10.1",
|
||||
"sonner": "^2.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"name": "chart",
|
||||
"type": "registry:ui",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
"type": "registry:ui",
|
||||
"author": "shadcn (https://ui.shadcn.com)",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"name": "chart",
|
||||
"type": "registry:ui",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
"type": "registry:ui",
|
||||
"author": "shadcn (https://ui.shadcn.com)",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"registry:theme",
|
||||
"registry:page",
|
||||
"registry:file",
|
||||
"registry:style"
|
||||
"registry:style",
|
||||
"registry:item"
|
||||
],
|
||||
"description": "The type of the item. This is used to determine the type and target path of the item when resolved for a project."
|
||||
},
|
||||
@@ -79,7 +80,8 @@
|
||||
"registry:theme",
|
||||
"registry:page",
|
||||
"registry:file",
|
||||
"registry:style"
|
||||
"registry:style",
|
||||
"registry:item"
|
||||
],
|
||||
"description": "The type of the file. This is used to determine the type of the file when resolved for a project."
|
||||
},
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
"name": "chart",
|
||||
"type": "registry:ui",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
@@ -73,7 +73,10 @@ function Calendar({
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ui: Registry["items"] = [
|
||||
},
|
||||
],
|
||||
registryDependencies: ["card"],
|
||||
dependencies: ["recharts", "lucide-react"],
|
||||
dependencies: ["recharts@2.15.4", "lucide-react"],
|
||||
},
|
||||
{
|
||||
name: "checkbox",
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"react-resizable-panels": "^2.0.22",
|
||||
"react-wrap-balancer": "^0.4.1",
|
||||
"recharts": "2.12.7",
|
||||
"shadcn": "2.8.0",
|
||||
"shadcn": "2.9.3",
|
||||
"sharp": "^0.32.6",
|
||||
"sonner": "^1.2.3",
|
||||
"swr": "2.2.6-beta.3",
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"name": "chart",
|
||||
"type": "registry:ui",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
"type": "registry:ui",
|
||||
"author": "shadcn (https://ui.shadcn.com)",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"name": "chart",
|
||||
"type": "registry:ui",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
"type": "registry:ui",
|
||||
"author": "shadcn (https://ui.shadcn.com)",
|
||||
"dependencies": [
|
||||
"recharts",
|
||||
"recharts@2.15.4",
|
||||
"lucide-react"
|
||||
],
|
||||
"registryDependencies": [
|
||||
|
||||
@@ -73,7 +73,10 @@ function Calendar({
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
|
||||
@@ -73,7 +73,10 @@ function Calendar({
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ui: Registry["items"] = [
|
||||
},
|
||||
],
|
||||
registryDependencies: ["card"],
|
||||
dependencies: ["recharts", "lucide-react"],
|
||||
dependencies: ["recharts@2.15.4", "lucide-react"],
|
||||
},
|
||||
{
|
||||
name: "checkbox",
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @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
|
||||
|
||||
- [#7782](https://github.com/shadcn-ui/ui/pull/7782) [`06d03d64f437b543bf5fa07ccbc559f285538ffd`](https://github.com/shadcn-ui/ui/commit/06d03d64f437b543bf5fa07ccbc559f285538ffd) Thanks [@shadcn](https://github.com/shadcn)! - add universal registry items support
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7795](https://github.com/shadcn-ui/ui/pull/7795) [`6c341c16aeaf5ade177a4a1ba4fb9afcd33d5fee`](https://github.com/shadcn-ui/ui/commit/6c341c16aeaf5ade177a4a1ba4fb9afcd33d5fee) Thanks [@shadcn](https://github.com/shadcn)! - fix safe target handling
|
||||
|
||||
- [#7757](https://github.com/shadcn-ui/ui/pull/7757) [`db93787712fe51346bf87dbae8b4cf4e38ed8c27`](https://github.com/shadcn-ui/ui/commit/db93787712fe51346bf87dbae8b4cf4e38ed8c27) Thanks [@shadcn](https://github.com/shadcn)! - implement registry path validation
|
||||
|
||||
## 2.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shadcn",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.3",
|
||||
"description": "Add components to your apps.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { preFlightAdd } from "@/src/preflights/preflight-add"
|
||||
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
|
||||
import { registryItemTypeSchema } from "@/src/registry/schema"
|
||||
import { isLocalFile, isUrl } from "@/src/registry/utils"
|
||||
import {
|
||||
isLocalFile,
|
||||
isUniversalRegistryItem,
|
||||
isUrl,
|
||||
} from "@/src/registry/utils"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { createProject } from "@/src/utils/create-project"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
@@ -78,13 +83,14 @@ export const add = new Command()
|
||||
})
|
||||
|
||||
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
|
||||
let registryItem: any = null
|
||||
|
||||
if (
|
||||
components.length > 0 &&
|
||||
(isUrl(components[0]) || isLocalFile(components[0]))
|
||||
) {
|
||||
const item = await getRegistryItem(components[0], "")
|
||||
itemType = item?.type
|
||||
registryItem = await getRegistryItem(components[0], "")
|
||||
itemType = registryItem?.type
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -130,6 +136,22 @@ export const add = new Command()
|
||||
}
|
||||
}
|
||||
|
||||
if (isUniversalRegistryItem(registryItem)) {
|
||||
// Universal items only cares about the cwd.
|
||||
if (!fs.existsSync(options.cwd)) {
|
||||
throw new Error(`Directory ${options.cwd} does not exist`)
|
||||
}
|
||||
|
||||
const minimalConfig = createConfig({
|
||||
resolvedPaths: {
|
||||
cwd: options.cwd,
|
||||
},
|
||||
})
|
||||
|
||||
await addComponents(options.components, minimalConfig, options)
|
||||
return
|
||||
}
|
||||
|
||||
let { errors, config } = await preFlightAdd(options)
|
||||
|
||||
// No components.json file. Prompt the user to run init.
|
||||
|
||||
@@ -13,6 +13,7 @@ export const registryItemTypeSchema = z.enum([
|
||||
"registry:file",
|
||||
"registry:theme",
|
||||
"registry:style",
|
||||
"registry:item",
|
||||
|
||||
// Internal use only
|
||||
"registry:example",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getDependencyFromModuleSpecifier, isLocalFile, isUrl } from "./utils"
|
||||
import {
|
||||
getDependencyFromModuleSpecifier,
|
||||
isLocalFile,
|
||||
isUniversalRegistryItem,
|
||||
isUrl,
|
||||
} from "./utils"
|
||||
|
||||
describe("getDependencyFromModuleSpecifier", () => {
|
||||
it("should return the first part of a non-scoped package with path", () => {
|
||||
@@ -130,3 +135,207 @@ describe("isLocalFile", () => {
|
||||
expect(isLocalFile("/absolute/path")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isUniversalRegistryItem", () => {
|
||||
it("should return true when all files have targets with registry:file type", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: "src/file1.ts",
|
||||
type: "registry:file" 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when some files lack targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: "src/file1.ts",
|
||||
type: "registry: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
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
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 },
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files array is empty", () => {
|
||||
const registryItem = {
|
||||
files: [],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files is undefined", () => {
|
||||
const registryItem = {}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when registryItem is null", () => {
|
||||
expect(isUniversalRegistryItem(null)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when registryItem is undefined", () => {
|
||||
expect(isUniversalRegistryItem(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when target is null", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: null as any,
|
||||
type: "registry:file" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when target is undefined", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
type: "registry:file" as const,
|
||||
target: undefined as any,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files have registry:component type even with targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "component.tsx",
|
||||
target: "components/ui/component.tsx",
|
||||
type: "registry:component" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files have registry:hook type even with targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "use-hook.ts",
|
||||
target: "hooks/use-hook.ts",
|
||||
type: "registry:hook" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files have registry:lib type even with targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "utils.ts",
|
||||
target: "lib/utils.ts",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
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
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle real-world example with path traversal attempts for registry:file", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "malicious.ts",
|
||||
target: "../../../etc/passwd",
|
||||
type: "registry:file" as const,
|
||||
},
|
||||
{
|
||||
path: "normal.ts",
|
||||
target: "src/normal.ts",
|
||||
type: "registry:file" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
// The function should still return true - path validation is handled elsewhere
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when files have non-registry:file type in a UI registry item", () => {
|
||||
const registryItem = {
|
||||
type: "registry:ui" as const,
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
target: "src/components/ui/button.tsx",
|
||||
type: "registry:ui" as const, // Not registry:file
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,3 +256,24 @@ export function isUrl(path: string) {
|
||||
export function isLocalFile(path: string) {
|
||||
return path.endsWith(".json") && !isUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
* It can be installed without framework detection or components.json.
|
||||
*/
|
||||
export function isUniversalRegistryItem(
|
||||
registryItem:
|
||||
| Pick<z.infer<typeof registryItemSchema>, "files">
|
||||
| null
|
||||
| undefined
|
||||
): boolean {
|
||||
return (
|
||||
!!registryItem?.files?.length &&
|
||||
registryItem.files.every(
|
||||
(file) => !!file.target && file.type === "registry:file"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import path from "path"
|
||||
import {
|
||||
fetchRegistry,
|
||||
getRegistryItem,
|
||||
getRegistryParentMap,
|
||||
getRegistryTypeAliasMap,
|
||||
registryResolveItemsTree,
|
||||
resolveRegistryItems,
|
||||
} from "@/src/registry/api"
|
||||
import { registryItemSchema } from "@/src/registry/schema"
|
||||
import {
|
||||
registryItemFileSchema,
|
||||
registryItemSchema,
|
||||
} from "@/src/registry/schema"
|
||||
import {
|
||||
configSchema,
|
||||
findCommonRoot,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
} from "@/src/utils/get-config"
|
||||
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { isSafeTarget } from "@/src/utils/is-safe-target"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { updateCss } from "@/src/utils/updaters/update-css"
|
||||
@@ -79,6 +84,14 @@ async function addProjectComponents(
|
||||
registrySpinner?.fail()
|
||||
return handleError(new Error("Failed to fetch components from registry."))
|
||||
}
|
||||
|
||||
try {
|
||||
validateFilesTarget(tree.files ?? [], config.resolvedPaths.cwd)
|
||||
} catch (error) {
|
||||
registrySpinner?.fail()
|
||||
return handleError(error)
|
||||
}
|
||||
|
||||
registrySpinner?.succeed()
|
||||
|
||||
const tailwindVersion = await getProjectTailwindVersionFromConfig(config)
|
||||
@@ -147,6 +160,13 @@ async function addWorkspaceComponents(
|
||||
const filesUpdated: string[] = []
|
||||
const filesSkipped: string[] = []
|
||||
|
||||
const files = payload.flatMap((item) => item.files ?? [])
|
||||
try {
|
||||
validateFilesTarget(files, config.resolvedPaths.cwd)
|
||||
} catch (error) {
|
||||
return handleError(error)
|
||||
}
|
||||
|
||||
const rootSpinner = spinner(`Installing components.`)?.start()
|
||||
|
||||
for (const component of payload) {
|
||||
@@ -308,8 +328,9 @@ async function shouldOverwriteCssVars(
|
||||
components: z.infer<typeof registryItemSchema>["name"][],
|
||||
config: z.infer<typeof configSchema>
|
||||
) {
|
||||
let registryItems = await resolveRegistryItems(components, config)
|
||||
let result = await fetchRegistry(registryItems)
|
||||
let result = await Promise.all(
|
||||
components.map((component) => getRegistryItem(component, config.style))
|
||||
)
|
||||
const payload = z.array(registryItemSchema).parse(result)
|
||||
|
||||
return payload.some(
|
||||
@@ -317,3 +338,20 @@ async function shouldOverwriteCssVars(
|
||||
component.type === "registry:theme" || component.type === "registry:style"
|
||||
)
|
||||
}
|
||||
|
||||
function validateFilesTarget(
|
||||
files: z.infer<typeof registryItemFileSchema>[],
|
||||
cwd: string
|
||||
) {
|
||||
for (const file of files) {
|
||||
if (!file?.target) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isSafeTarget(file.target, cwd)) {
|
||||
throw new Error(
|
||||
`We found an unsafe file path "${file.target} in the registry item. Installation aborted.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,3 +225,64 @@ export async function getTargetStyleFromConfig(cwd: string, fallback: string) {
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
return projectInfo?.tailwindVersion === "v4" ? "new-york-v4" : fallback
|
||||
}
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a config object with sensible defaults.
|
||||
* Useful for universal registry items that bypass framework detection.
|
||||
*
|
||||
* @param partial - Partial config values to override defaults
|
||||
* @returns A complete Config object
|
||||
*/
|
||||
export function createConfig(partial?: DeepPartial<Config>): Config {
|
||||
const defaultConfig: Config = {
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindConfig: "",
|
||||
tailwindCss: "",
|
||||
utils: "",
|
||||
components: "",
|
||||
ui: "",
|
||||
lib: "",
|
||||
hooks: "",
|
||||
},
|
||||
style: "",
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "",
|
||||
baseColor: "",
|
||||
cssVariables: false,
|
||||
},
|
||||
rsc: false,
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "",
|
||||
utils: "",
|
||||
},
|
||||
}
|
||||
|
||||
// Deep merge the partial config with defaults
|
||||
if (partial) {
|
||||
return {
|
||||
...defaultConfig,
|
||||
...partial,
|
||||
resolvedPaths: {
|
||||
...defaultConfig.resolvedPaths,
|
||||
...(partial.resolvedPaths || {}),
|
||||
},
|
||||
tailwind: {
|
||||
...defaultConfig.tailwind,
|
||||
...(partial.tailwind || {}),
|
||||
},
|
||||
aliases: {
|
||||
...defaultConfig.aliases,
|
||||
...(partial.aliases || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
156
packages/shadcn/src/utils/is-safe-target.test.ts
Normal file
156
packages/shadcn/src/utils/is-safe-target.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "vitest"
|
||||
|
||||
import { isSafeTarget } from "./is-safe-target"
|
||||
|
||||
describe("isSafeTarget", () => {
|
||||
const cwd = "/foo/bar"
|
||||
|
||||
describe("should reject path traversal attempts", () => {
|
||||
test.each([
|
||||
{
|
||||
description: "basic path traversal with ../",
|
||||
target: "../../etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "nested path traversal",
|
||||
target: "ui/../../../etc/hosts",
|
||||
},
|
||||
{
|
||||
description: "path traversal with ~/../",
|
||||
target: "~/../../../.ssh/authorized_keys",
|
||||
},
|
||||
{
|
||||
description: "absolute paths outside project",
|
||||
target: "/etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "paths that resolve outside project root",
|
||||
target: "foo/bar/../../../../etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "URL-encoded path traversal",
|
||||
target: "%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||||
},
|
||||
{
|
||||
description: "double URL-encoded sequences",
|
||||
target: "%252e%252e%252fetc%252fpasswd",
|
||||
},
|
||||
{
|
||||
description: "mixed encoded/plain traversal",
|
||||
target: "..%2f..%2fetc%2fpasswd",
|
||||
},
|
||||
{
|
||||
description: "null byte injection",
|
||||
target: "valid/path\0../../etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "Windows-style path traversal",
|
||||
target: "..\\..\\Windows\\System32\\config",
|
||||
},
|
||||
{
|
||||
description: "Windows absolute paths",
|
||||
target: "C:\\Windows\\System32\\drivers\\etc\\hosts",
|
||||
},
|
||||
{
|
||||
description: "mixed separator traversal",
|
||||
target: "foo\\..\\../etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "current directory reference attacks",
|
||||
target: "foo/./././../../../etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "control characters in paths",
|
||||
target: "foo/\x01\x02/../../etc/passwd",
|
||||
},
|
||||
{
|
||||
description: "Unicode normalization attacks",
|
||||
target: "foo/../\u2025/etc/passwd",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"path traversal with square brackets outside [...] pattern",
|
||||
target: "foo/[bar]/../../etc/passwd",
|
||||
},
|
||||
])("$description", ({ target }) => {
|
||||
expect(isSafeTarget(target, cwd)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("should accept safe paths", () => {
|
||||
test.each([
|
||||
{
|
||||
description: "simple relative path",
|
||||
target: "ui/button.tsx",
|
||||
},
|
||||
{
|
||||
description: "nested relative path",
|
||||
target: "components/ui/button.tsx",
|
||||
},
|
||||
{
|
||||
description: "home directory expansion",
|
||||
target: "~/foo.json",
|
||||
},
|
||||
{
|
||||
description: "nested home directory path",
|
||||
target: "~/components/button.tsx",
|
||||
},
|
||||
{
|
||||
description: "dot in filename",
|
||||
target: "components/.env.local",
|
||||
},
|
||||
{
|
||||
description: "path with spaces",
|
||||
target: "my components/button.tsx",
|
||||
},
|
||||
{
|
||||
description: "path with special characters",
|
||||
target: "components/@ui/button.tsx",
|
||||
},
|
||||
{
|
||||
description: "framework routing with square brackets",
|
||||
target: "pages/[id].tsx",
|
||||
},
|
||||
{
|
||||
description: "catch-all routes with [...param]",
|
||||
target: "server/api/auth/[...].ts",
|
||||
},
|
||||
{
|
||||
description: "optional catch-all routes",
|
||||
target: "pages/[[...slug]].tsx",
|
||||
},
|
||||
{
|
||||
description: "dollar sign routes",
|
||||
target: "routes/$userId.tsx",
|
||||
},
|
||||
{
|
||||
description: "complex routing patterns",
|
||||
target: "app/[locale]/[...segments]/page.tsx",
|
||||
},
|
||||
])("$description", ({ target }) => {
|
||||
expect(isSafeTarget(target, cwd)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should handle empty string", () => {
|
||||
expect(isSafeTarget("", cwd)).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle single dot", () => {
|
||||
expect(isSafeTarget(".", cwd)).toBe(true)
|
||||
})
|
||||
|
||||
test("should reject malformed URL encoding", () => {
|
||||
expect(isSafeTarget("%zz%ff%2e%2e%2f", cwd)).toBe(false)
|
||||
})
|
||||
|
||||
test("should handle paths at project root", () => {
|
||||
expect(isSafeTarget("/foo/bar/test.txt", cwd)).toBe(true)
|
||||
})
|
||||
|
||||
test("should reject paths just outside project root", () => {
|
||||
expect(isSafeTarget("/foo/test.txt", cwd)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
98
packages/shadcn/src/utils/is-safe-target.ts
Normal file
98
packages/shadcn/src/utils/is-safe-target.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import path from "path"
|
||||
|
||||
export function isSafeTarget(targetPath: string, cwd: string): boolean {
|
||||
// Check for null bytes which can be used to bypass validations.
|
||||
if (targetPath.includes("\0")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode URL-encoded sequences to catch encoded traversal attempts.
|
||||
let decodedPath: string
|
||||
try {
|
||||
// Decode multiple times to catch double-encoded sequences.
|
||||
decodedPath = targetPath
|
||||
let prevPath = ""
|
||||
while (decodedPath !== prevPath && decodedPath.includes("%")) {
|
||||
prevPath = decodedPath
|
||||
decodedPath = decodeURIComponent(decodedPath)
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, treat as unsafe.
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize both paths to handle different path separators.
|
||||
// Convert Windows backslashes to forward slashes for consistent handling.
|
||||
const normalizedTarget = path.normalize(decodedPath.replace(/\\/g, "/"))
|
||||
const normalizedRoot = path.normalize(cwd)
|
||||
|
||||
// Check for explicit path traversal sequences in both encoded and decoded forms.
|
||||
// Allow [...] pattern which is common in framework routing (e.g., [...slug])
|
||||
const hasPathTraversal = (path: string) => {
|
||||
// Remove [...] patterns before checking for ..
|
||||
const withoutBrackets = path.replace(/\[\.\.\..*?\]/g, "")
|
||||
return withoutBrackets.includes("..")
|
||||
}
|
||||
|
||||
if (
|
||||
hasPathTraversal(normalizedTarget) ||
|
||||
hasPathTraversal(decodedPath) ||
|
||||
hasPathTraversal(targetPath)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for current directory references that might be used in traversal.
|
||||
// First, remove [...] patterns to avoid false positives
|
||||
const cleanPath = (path: string) => path.replace(/\[\.\.\..*?\]/g, "")
|
||||
const cleanTarget = cleanPath(targetPath)
|
||||
const cleanDecoded = cleanPath(decodedPath)
|
||||
|
||||
const suspiciousPatterns = [
|
||||
/\.\.[\/\\]/, // ../ or ..\
|
||||
/[\/\\]\.\./, // /.. or \..
|
||||
/\.\./, // .. anywhere
|
||||
/\.\.%/, // URL encoded traversal
|
||||
/\x00/, // null byte
|
||||
/[\x01-\x1f]/, // control characters
|
||||
]
|
||||
|
||||
if (
|
||||
suspiciousPatterns.some(
|
||||
(pattern) => pattern.test(cleanTarget) || pattern.test(cleanDecoded)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow ~/ at the start (home directory expansion within project) but reject ~/../ patterns.
|
||||
if (
|
||||
(targetPath.includes("~") || decodedPath.includes("~")) &&
|
||||
(targetPath.includes("../") || decodedPath.includes("../"))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for Windows drive letters (even on non-Windows systems for safety).
|
||||
const driveLetterRegex = /^[a-zA-Z]:[\/\\]/
|
||||
if (driveLetterRegex.test(decodedPath)) {
|
||||
// On Windows, check if it starts with the project root.
|
||||
if (process.platform === "win32") {
|
||||
return decodedPath.toLowerCase().startsWith(cwd.toLowerCase())
|
||||
}
|
||||
// On non-Windows systems, reject all Windows absolute paths.
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's an absolute path, ensure it's within the project root.
|
||||
if (path.isAbsolute(normalizedTarget)) {
|
||||
return normalizedTarget.startsWith(normalizedRoot + path.sep)
|
||||
}
|
||||
|
||||
// For relative paths, resolve and check if within project bounds.
|
||||
const resolvedPath = path.resolve(normalizedRoot, normalizedTarget)
|
||||
return (
|
||||
resolvedPath.startsWith(normalizedRoot + path.sep) ||
|
||||
resolvedPath === normalizedRoot
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,9 @@ export async function updateFiles(
|
||||
|
||||
const [projectInfo, baseColor] = await Promise.all([
|
||||
getProjectInfo(config.resolvedPaths.cwd),
|
||||
getRegistryBaseColor(config.tailwind.baseColor),
|
||||
config.tailwind.baseColor
|
||||
? getRegistryBaseColor(config.tailwind.baseColor)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
|
||||
let filesCreated: string[] = []
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import path from "path"
|
||||
import { expect, test } from "vitest"
|
||||
import { describe, expect, test } from "vitest"
|
||||
|
||||
import { getConfig, getRawConfig } from "../../src/utils/get-config"
|
||||
import {
|
||||
createConfig,
|
||||
getConfig,
|
||||
getRawConfig,
|
||||
} from "../../src/utils/get-config"
|
||||
|
||||
test("get raw config", async () => {
|
||||
expect(
|
||||
@@ -183,3 +187,129 @@ test("get config", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("createConfig", () => {
|
||||
test("creates default config when called without arguments", () => {
|
||||
const config = createConfig()
|
||||
|
||||
expect(config).toMatchObject({
|
||||
resolvedPaths: {
|
||||
cwd: expect.any(String),
|
||||
tailwindConfig: "",
|
||||
tailwindCss: "",
|
||||
utils: "",
|
||||
components: "",
|
||||
ui: "",
|
||||
lib: "",
|
||||
hooks: "",
|
||||
},
|
||||
style: "",
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "",
|
||||
baseColor: "",
|
||||
cssVariables: false,
|
||||
},
|
||||
rsc: false,
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "",
|
||||
utils: "",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("overrides cwd in resolvedPaths", () => {
|
||||
const customCwd = "/custom/path"
|
||||
const config = createConfig({
|
||||
resolvedPaths: {
|
||||
cwd: customCwd,
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.resolvedPaths.cwd).toBe(customCwd)
|
||||
expect(config.resolvedPaths.components).toBe("")
|
||||
expect(config.resolvedPaths.utils).toBe("")
|
||||
})
|
||||
|
||||
test("overrides style", () => {
|
||||
const config = createConfig({
|
||||
style: "new-york",
|
||||
})
|
||||
|
||||
expect(config.style).toBe("new-york")
|
||||
})
|
||||
|
||||
test("overrides tailwind settings", () => {
|
||||
const config = createConfig({
|
||||
tailwind: {
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.tailwind.baseColor).toBe("slate")
|
||||
expect(config.tailwind.cssVariables).toBe(true)
|
||||
expect(config.tailwind.config).toBe("")
|
||||
expect(config.tailwind.css).toBe("")
|
||||
})
|
||||
|
||||
test("overrides boolean flags", () => {
|
||||
const config = createConfig({
|
||||
rsc: true,
|
||||
tsx: false,
|
||||
})
|
||||
|
||||
expect(config.rsc).toBe(true)
|
||||
expect(config.tsx).toBe(false)
|
||||
})
|
||||
|
||||
test("overrides aliases", () => {
|
||||
const config = createConfig({
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.aliases.components).toBe("@/components")
|
||||
expect(config.aliases.utils).toBe("@/lib/utils")
|
||||
})
|
||||
|
||||
test("handles complex partial overrides", () => {
|
||||
const config = createConfig({
|
||||
style: "default",
|
||||
resolvedPaths: {
|
||||
cwd: "/my/project",
|
||||
components: "/my/project/src/components",
|
||||
},
|
||||
tailwind: {
|
||||
baseColor: "zinc",
|
||||
prefix: "tw-",
|
||||
},
|
||||
aliases: {
|
||||
ui: "@/components/ui",
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.style).toBe("default")
|
||||
expect(config.resolvedPaths.cwd).toBe("/my/project")
|
||||
expect(config.resolvedPaths.components).toBe("/my/project/src/components")
|
||||
expect(config.resolvedPaths.utils).toBe("")
|
||||
expect(config.tailwind.baseColor).toBe("zinc")
|
||||
expect(config.tailwind.prefix).toBe("tw-")
|
||||
expect(config.tailwind.css).toBe("")
|
||||
expect(config.aliases.ui).toBe("@/components/ui")
|
||||
expect(config.aliases.components).toBe("")
|
||||
})
|
||||
|
||||
test("returns new object instances", () => {
|
||||
const config1 = createConfig()
|
||||
const config2 = createConfig()
|
||||
|
||||
expect(config1).not.toBe(config2)
|
||||
expect(config1.resolvedPaths).not.toBe(config2.resolvedPaths)
|
||||
expect(config1.tailwind).not.toBe(config2.tailwind)
|
||||
expect(config1.aliases).not.toBe(config2.aliases)
|
||||
})
|
||||
})
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -322,7 +322,7 @@ importers:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
shadcn:
|
||||
specifier: 2.8.0
|
||||
specifier: 2.9.3
|
||||
version: link:../../packages/shadcn
|
||||
shiki:
|
||||
specifier: ^1.10.1
|
||||
@@ -602,7 +602,7 @@ importers:
|
||||
specifier: 2.12.7
|
||||
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
shadcn:
|
||||
specifier: 2.8.0
|
||||
specifier: 2.9.3
|
||||
version: link:../../packages/shadcn
|
||||
sharp:
|
||||
specifier: ^0.32.6
|
||||
|
||||
Reference in New Issue
Block a user