Compare commits

...

42 Commits

Author SHA1 Message Date
shadcn
39eb34104b Merge pull request #10444 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-21 16:28:15 +04:00
github-actions[bot]
7cbc7e8d53 chore(release): version packages 2026-04-21 12:26:54 +00:00
shadcn
d0ac558ce2 Merge pull request #10396 from ramonclaudio/docs/dark-mode-tanstack-start
docs(dark-mode): add tanstack start guide
2026-04-21 16:25:48 +04:00
shadcn
bc0c46a93c Merge pull request #10445 from ericzakariasson/add-cursor-plugin-manifest
feat: add Cursor plugin manifest
2026-04-21 16:23:46 +04:00
shadcn
a64575d8a4 Merge pull request #10454 from Bartek532/feat/loading-ui-registry
feat: add loading-ui registry to index
2026-04-21 16:22:16 +04:00
shadcn
5d0cd7819b Merge pull request #10449 from radiumcoders/main
Add @xcn registry entry
2026-04-21 16:21:23 +04:00
shadcn
13478b26b6 Merge pull request #10451 from shadcn-ui/shadcn/apply-only
feat: add apply --only
2026-04-21 16:20:14 +04:00
Jay Sharma
aee8a71679 Merge branch 'main' into main 2026-04-21 17:18:34 +05:30
Bartek532
4507f1c794 chore: refine loading-ui registry description
Update the Loading UI directory description copy and regenerate the public registries index to keep generated metadata in sync.
2026-04-21 13:22:24 +02:00
Bartosz Zagrodzki
81cd2266aa Merge branch 'main' into feat/loading-ui-registry 2026-04-21 13:20:14 +02:00
Bartek532
cf756b1b55 feat: add loading-ui registry to index
Add the Loading UI registry to the curated directory and regenerate registries.json so the new source appears in the public registry index.
2026-04-21 13:19:18 +02:00
shadcn
5e61f9c4a4 test: ensure --radius is coming through 2026-04-21 13:03:40 +04:00
shadcn
c4def9305f docs: update 2026-04-21 13:03:25 +04:00
shadcn
e456fed9d3 feat: add apply --only 2026-04-21 12:57:56 +04:00
Ray
b95cd29508 Merge branch 'main' into docs/dark-mode-tanstack-start 2026-04-21 03:46:24 -04:00
shadcn
11cbc32840 refactor: caching for build registry 2026-04-21 11:25:56 +04:00
shadcn
01539fb4d7 refactor: add getThemeScript 2026-04-21 10:35:34 +04:00
Radiumcoders
e47ee89dcf Add @xcn registry entry 2026-04-20 20:16:26 +05:30
Ray
2f5c32c0b1 Merge branch 'main' into docs/dark-mode-tanstack-start 2026-04-20 10:22:34 -04:00
ericzakariasson
fbfe9f34bb feat: add Cursor plugin manifest
Adds .cursor-plugin/plugin.json so this repo installs as a Cursor
plugin via /add-plugin shadcn-ui/ui.

- Loads the existing skills/shadcn/SKILL.md skill (auto-discovered
  via the manifest's skills field).
- Registers the shadcn MCP server (npx shadcn@latest mcp) inline so
  users get the same MCP config already documented for every other
  client without hand-editing .cursor/mcp.json.
- Reuses skills/shadcn/assets/shadcn.png as the plugin logo.

No skill content or MCP changes — purely manifest wiring.

Made-with: Cursor
2026-04-20 10:40:32 +02:00
shadcn
d55e059fda Merge pull request #10440 from uiNerd16/add-aicanvas-registry
feat: add @aicanvas registry
2026-04-20 12:37:59 +04:00
shadcn
9c572ab778 fix: chartColor in presets 2026-04-20 12:29:55 +04:00
shadcn
91403eeb63 Merge pull request #10439 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-20 12:18:33 +04:00
github-actions[bot]
3411d53856 chore(release): version packages 2026-04-20 08:16:18 +00:00
shadcn
efa2b38d07 Merge pull request #10179 from EthanThatOneKid/fix/accept-header-issue-10164
fix(cli): add Accept: application/json header to registry fetch
2026-04-20 12:15:20 +04:00
shadcn
d00605c5fb chore: changeset 2026-04-20 11:55:18 +04:00
shadcn
4bdeea4c63 docs: update docs 2026-04-20 11:55:13 +04:00
shadcn
f632f5d798 feat: rename header 2026-04-20 11:55:06 +04:00
Ethan Davidson
7d6d489f83 Merge branch 'main' into fix/accept-header-issue-10164 2026-04-19 15:59:29 -07:00
uiNerd16
e8b1be1f22 feat: add @aicanvas registry 2026-04-19 22:10:56 +02:00
shadcn
d987955893 Merge pull request #10399 from ysds/registry-exabase
chore(registry): add @exabase registry
2026-04-19 20:54:40 +04:00
shadcn
7b5435ac0b Merge pull request #10436 from shadcn-ui/shadcn/fix-init-git-new-project-only
fix: ensure git init runs for new projects only
2026-04-19 20:49:03 +04:00
shadcn
f289497e35 Merge branch 'main' into shadcn/fix-init-git-new-project-only 2026-04-19 15:06:58 +04:00
shadcn
0d266984e6 Merge pull request #10438 from shadcn-ui/shadcn/release-workflows
Consolidate release workflows and clarify run names
2026-04-19 15:06:49 +04:00
shadcn
cf92d4f8f2 Consolidate release workflows and beta comment handling 2026-04-19 14:59:14 +04:00
shadcn
b7cfc364ac chore: changeset 2026-04-19 13:11:24 +04:00
shadcn
de385d04fc fix: ensure git init runs for new projects only 2026-04-19 12:55:07 +04:00
ysds
eeb5d22fe5 chore(registry): add @exabase registry 2026-04-15 12:08:19 +09:00
Ray
a757e80242 docs(dark-mode): add tanstack start guide 2026-04-14 15:31:40 -04:00
Ethan Davidson
945298ed2d Merge branch 'main' into fix/accept-header-issue-10164 2026-03-26 00:29:45 -07:00
Ethan Davidson
f9b216af77 docs(registry): document content negotiation with Express example 2026-03-26 00:24:48 -07:00
Ethan Davidson
6525227036 fix(cli): add Accept and User-Agent headers to support content negotiation (fixes #10164) 2026-03-26 00:24:48 -07:00
30 changed files with 1682 additions and 122 deletions

View File

@@ -0,0 +1,41 @@
{
"name": "shadcn",
"displayName": "shadcn/ui",
"version": "1.0.0",
"description": "Official shadcn/ui Cursor plugin. Loads the shadcn agent skill (skills/shadcn/SKILL.md) and registers the shadcn MCP server (npx shadcn@latest mcp) so Cursor agents can browse registries, search, view, install, and audit components.",
"author": {
"name": "shadcn"
},
"homepage": "https://ui.shadcn.com",
"repository": "https://github.com/shadcn-ui/ui",
"license": "MIT",
"logo": "skills/shadcn/assets/shadcn.png",
"keywords": [
"shadcn",
"shadcn-ui",
"ui",
"components",
"tailwind",
"tailwindcss",
"radix",
"react",
"design-system",
"registry",
"mcp"
],
"category": "developer-tools",
"tags": [
"ui",
"components",
"design-system",
"react",
"tailwind"
],
"skills": "./skills/",
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}

View File

@@ -3,7 +3,7 @@ name: Write Beta Release comment
on:
workflow_run:
workflows: ["Release - Beta"]
workflows: ["Release"]
types:
- completed
@@ -11,12 +11,13 @@ jobs:
comment:
if: |
github.repository_owner == 'shadcn-ui' &&
${{ github.event.workflow_run.conclusion == 'success' }}
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
name: Write comment to the PR
steps:
- name: "Comment on PR"
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -53,7 +54,7 @@ jobs:
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -1,64 +0,0 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
jobs:
prerelease:
if: |
github.repository_owner == 'shadcn-ui' &&
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
name: Build & Publish a beta release to NPM
runs-on: ubuntu-latest
environment: Preview
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js

View File

@@ -2,24 +2,81 @@
name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
on:
pull_request:
types: [labeled]
branches:
- main
push:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
release:
if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create a PR for release workflow
prerelease:
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
name: Publish Beta to NPM
runs-on: ubuntu-latest
environment: Preview
permissions:
id-token: write
contents: read
steps:
- name: Checkout Repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js
release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
name: Create Version PR or Publish Stable Release
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -3,6 +3,21 @@ import { describe, expect, it } from "vitest"
import { parseDesignSystemConfig } from "./parse-config"
describe("parseDesignSystemConfig", () => {
it("defaults missing chartColor from the selected theme", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.chartColor).toBe("taupe")
})
it("honors explicit fontHeading and chartColor overrides when a preset is present", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest"
import { buildRegistryBase, DEFAULT_CONFIG } from "@/registry/config"
import { GET } from "./route"
function createRequest(search = "") {
const searchParams = new URLSearchParams(
Object.entries(DEFAULT_CONFIG).map(([key, value]) => [key, String(value)])
)
const url = new URL(`http://localhost:4000/init${search}`)
for (const [key, value] of url.searchParams) {
searchParams.set(key, value)
}
return {
nextUrl: new URL(`http://localhost:4000/init?${searchParams}`),
} as Parameters<typeof GET>[0]
}
describe("GET /init", () => {
it("returns the full registry base when only is omitted", async () => {
const response = await GET(createRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json).toEqual(buildRegistryBase(DEFAULT_CONFIG))
})
it("returns a sparse registry base when only is provided", async () => {
const response = await GET(createRequest("?only=theme"))
const json = await response.json()
expect(response.status).toBe(200)
expect(json.type).toBe("registry:base")
expect(json.config).toEqual({
menuColor: "default",
menuAccent: "subtle",
tailwind: {
baseColor: "neutral",
},
})
expect(json.cssVars.light).toBeDefined()
expect(json.cssVars.light.radius).toBe("0.625rem")
expect(json.dependencies).toBeUndefined()
expect(json.registryDependencies).toBeUndefined()
})
it("rejects unsupported only values", async () => {
const response = await GET(createRequest("?only=icon"))
const json = await response.json()
expect(response.status).toBe(400)
expect(json.error).toBe(
"Invalid only value. Use one or more of: theme, font"
)
})
})

View File

@@ -3,7 +3,11 @@ import { track } from "@vercel/analytics/server"
import { isPresetCode } from "shadcn/preset"
import { registryItemSchema } from "shadcn/schema"
import { buildRegistryBase } from "@/registry/config"
import {
buildPartialRegistryBase,
buildRegistryBase,
parseRegistryBaseParts,
} from "@/registry/config"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
@@ -16,13 +20,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const onlyResult = parseRegistryBaseParts(searchParams.get("only"))
if (!onlyResult.success) {
return NextResponse.json({ error: onlyResult.error }, { status: 400 })
}
const rawPreset = searchParams.get("preset")
const presetCode =
rawPreset && isPresetCode(rawPreset)
? rawPreset
: getPresetCode(result.data)
const registryBase = buildRegistryBase(result.data)
const registryBase = onlyResult.parts
? buildPartialRegistryBase(result.data, onlyResult.parts)
: buildRegistryBase(result.data)
const parseResult = registryItemSchema.safeParse(registryBase)
if (!parseResult.success) {

View File

@@ -93,6 +93,14 @@ Use the `apply` command to apply a preset to an existing project.
npx shadcn@latest apply --preset a2r6bw
```
You can apply only the theme or fonts from a preset without reinstalling UI components:
```bash
npx shadcn@latest apply --preset a2r6bw --only theme
```
Supported values for `--only` are `theme` and `font`.
**Options**
```bash
@@ -105,6 +113,7 @@ Arguments:
Options:
--preset <preset> preset configuration to apply
--only [parts] apply only parts of a preset: theme, font
-y, --yes skip confirmation prompt. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
-s, --silent mute output. (default: false)

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
{
"title": "Dark mode",
"pages": ["index", "next", "vite", "astro", "remix"]
"pages": ["index", "next", "vite", "astro", "remix", "tanstack-start"]
}

View File

@@ -0,0 +1,191 @@
---
title: TanStack Start
description: Adding dark mode to your TanStack Start app.
---
<Steps>
### Create a theme provider
TanStack Start uses `ScriptOnce` from `@tanstack/react-router` to inject a script that runs before React hydrates, preventing flash of unstyled content (FOUC).
```tsx title="components/theme-provider.tsx" showLineNumbers
import { createContext, useContext, useEffect, useState } from "react"
import { ScriptOnce } from "@tanstack/react-router"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
function getThemeScript(storageKey: string, defaultTheme: Theme) {
const key = JSON.stringify(storageKey)
const fallback = JSON.stringify(defaultTheme)
return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`
}
const ThemeProviderContext = createContext<ThemeProviderState>({
theme: "system",
setTheme: () => {},
})
function applyTheme(theme: Theme) {
const root = document.documentElement
root.classList.remove("light", "dark")
const resolved =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme
root.classList.add(resolved)
root.style.colorScheme = resolved
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "theme",
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(defaultTheme)
const [mounted, setMounted] = useState(false)
useEffect(() => {
const stored = localStorage.getItem(storageKey)
setThemeState(
stored === "light" || stored === "dark" || stored === "system"
? stored
: defaultTheme
)
setMounted(true)
}, [defaultTheme, storageKey])
useEffect(() => {
if (!mounted) return
applyTheme(theme)
}, [theme, mounted])
useEffect(() => {
if (!mounted || theme !== "system") return
const media = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = () => applyTheme("system")
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}, [theme, mounted])
const setTheme = (next: Theme) => {
localStorage.setItem(storageKey, next)
setThemeState(next)
}
return (
<ThemeProviderContext value={{ theme, setTheme }}>
<ScriptOnce>{getThemeScript(storageKey, defaultTheme)}</ScriptOnce>
{children}
</ThemeProviderContext>
)
}
export function useTheme() {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}
```
### Wrap your root layout
Add the `ThemeProvider` to your root layout and add the `suppressHydrationWarning` prop to the `html` tag.
```tsx {8,19,24-26} title="src/routes/__root.tsx" showLineNumbers
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router"
import { ThemeProvider } from "@/components/theme-provider"
export const Route = createRootRoute({
head: () => ({
// ...
}),
component: RootComponent,
})
function RootComponent() {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider defaultTheme="system" storageKey="theme">
<Outlet />
</ThemeProvider>
<Scripts />
</body>
</html>
)
}
```
### Add a mode toggle
Place a mode toggle on your site to toggle between light and dark mode.
```tsx title="components/mode-toggle.tsx" showLineNumbers
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
```
</Steps>

View File

@@ -144,6 +144,106 @@ npm run dev
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
## Content negotiation
The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint — including the root of your domain — and serve different content depending on who is asking.
From a single URL, you can serve:
- **HTML** to browsers — a landing page, documentation, or marketing site.
- **JSON** to the `shadcn` CLI — an installable registry item.
- **Markdown** to AI agents and LLMs — a machine-readable version of your content.
The client signals its preference using the `Accept` request header, and your server decides what to return.
### Request headers
When the CLI makes a request to a registry, it sends the following headers:
- **User-Agent**: `shadcn`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
### Root hosting
By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage.
The examples below assume your built registry item is served at `/r/index.json`. Adjust the path to match your output.
In Next.js, express this as a rewrite in `next.config.ts`. This keeps the negotiation in the routing layer and avoids a Proxy function for this static rewrite:
```typescript title="next.config.ts" showLineNumbers
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: "/",
has: [
{
type: "header",
key: "accept",
value: "(.*)application/vnd\\.shadcn\\.v1\\+json(.*)",
},
],
destination: "/r/index.json",
},
{
source: "/",
has: [
{
type: "header",
key: "user-agent",
value: "shadcn",
},
],
destination: "/r/index.json",
},
],
}
},
async headers() {
return [
{
source: "/",
headers: [{ key: "Vary", value: "Accept, User-Agent" }],
},
]
},
}
export default nextConfig
```
Or, in an Express.js server:
```javascript title="server.js" showLineNumbers
app.get("/", (req, res) => {
res.vary("Accept")
res.vary("User-Agent")
// Check if the client prefers the shadcn vendor type.
if (req.accepts("application/vnd.shadcn.v1+json")) {
return res.json(registryData)
}
// Optional: Secondary check for the User-Agent.
if (req.get("User-Agent") === "shadcn") {
return res.json(registryData)
}
// Otherwise, serve your documentation or homepage.
res.send(htmlContent)
})
```
This enables:
- **Branded Registry URLs**: `shadcn add https://ui.example.com`
- **Shorter URLs**: Users type your domain root, not `/r/` or `/registry/` sub-paths.
- **Easy Mnemonics**: Easier for users to remember and share your registry.
## Publish your registry
To make your registry available to other developers, you can publish it by deploying your project to a public URL.

View File

@@ -76,7 +76,7 @@
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"server-only": "^0.0.1",
"shadcn": "4.3.0",
"shadcn": "4.4.0",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"swr": "^2.3.6",

View File

@@ -347,6 +347,12 @@
"url": "https://limeplay.winoffrg.dev/r/{name}.json",
"description": "Modern UI Library for building media players in React. Powered by Shaka Player."
},
{
"name": "@loading-ui",
"homepage": "https://loading-ui.com",
"url": "https://loading-ui.com/r/{name}.json",
"description": "Spinners, loaders, and loading animations for modern web apps. Free and open-source."
},
{
"name": "@lmscn",
"homepage": "https://lmscn.vercel.app",
@@ -1030,5 +1036,23 @@
"homepage": "https://www.remocn.dev/",
"url": "https://www.remocn.dev/r/{name}.json",
"description": "Production-ready components for Remotion - text animations, backgrounds, transitions, UI blocks, and full scene compositions"
},
{
"name": "@exabase",
"homepage": "https://exawizards.com/exabase/design/",
"url": "https://exawizards.com/exabase/design/registry/{name}.json",
"description": "A collection of UI components based on the exaBase Design System, built with React and Tailwind CSS."
},
{
"name": "@aicanvas",
"homepage": "https://aicanvas.me",
"url": "https://aicanvas.me/r/{name}.json",
"description": "54 animated React components with AI reproduction prompts for Claude Code, Lovable, and v0. Free and open source."
},
{
"name": "@xcn",
"homepage": "https://ui.radiumcoders.com",
"url": "https://ui.radiumcoders.com/r/xcn/{name}.json",
"description": "Hand-crafted, beautiful, and minimal UI components built with Tailwind CSS and Motion."
}
]

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest"
import {
buildPartialRegistryBase,
buildRegistryBase,
DEFAULT_CONFIG,
designSystemConfigSchema,
parseRegistryBaseParts,
} from "./config"
describe("buildRegistryBase", () => {
@@ -65,6 +67,23 @@ describe("buildRegistryBase", () => {
expect(result.chartColor).toBe("neutral")
})
it("defaults chartColor to the selected theme when omitted", () => {
const result = designSystemConfigSchema.parse({
base: "base",
style: "sera",
iconLibrary: "lucide",
baseColor: "taupe",
theme: "taupe",
font: "noto-sans",
fontHeading: "playfair-display",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
})
expect(result.chartColor).toBe("taupe")
})
it("rejects chartColor values that are unavailable for the selected base color", () => {
const result = designSystemConfigSchema.safeParse({
base: "radix",
@@ -83,3 +102,105 @@ describe("buildRegistryBase", () => {
expect(result.success).toBe(false)
})
})
describe("parseRegistryBaseParts", () => {
it("returns undefined parts when only is omitted", () => {
expect(parseRegistryBaseParts(null)).toEqual({
success: true,
parts: undefined,
})
})
it("normalizes and dedupes only values", () => {
expect(parseRegistryBaseParts("theme,fonts,font")).toEqual({
success: true,
parts: ["theme", "font"],
})
})
it("rejects invalid only values", () => {
const result = parseRegistryBaseParts("theme,colors")
expect(result.success).toBe(false)
})
})
describe("buildPartialRegistryBase", () => {
it("builds a sparse theme payload", () => {
const result = buildPartialRegistryBase(
{
...DEFAULT_CONFIG,
baseColor: "taupe",
theme: "taupe",
chartColor: "taupe",
menuAccent: "bold",
menuColor: "inverted",
radius: "large",
},
["theme"]
)
expect(result.type).toBe("registry:base")
expect(result.extends).toBe("none")
expect(result.config).toEqual({
menuColor: "inverted",
menuAccent: "bold",
tailwind: {
baseColor: "taupe",
},
})
expect(result.cssVars?.light?.radius).toBe("0.875rem")
expect(result.cssVars?.light).toBeDefined()
expect(result.cssVars?.dark).toBeDefined()
expect(result.registryDependencies).toBeUndefined()
expect("dependencies" in result).toBe(false)
})
it("builds a sparse font payload", () => {
const result = buildPartialRegistryBase(
{
...DEFAULT_CONFIG,
font: "noto-sans",
fontHeading: "playfair-display",
},
["font"]
)
expect(result.registryDependencies).toEqual([
"font-noto-sans",
"font-heading-playfair-display",
])
expect(result.cssVars).toBeUndefined()
expect(result.config).toBeUndefined()
})
it("adds a heading fallback when font heading inherits", () => {
const result = buildPartialRegistryBase(
{
...DEFAULT_CONFIG,
font: "jetbrains-mono",
fontHeading: "inherit",
},
["font"]
)
expect(result.registryDependencies).toEqual(["font-jetbrains-mono"])
expect(result.cssVars?.theme?.["--font-heading"]).toBe("var(--font-mono)")
})
it("merges combined partial payloads", () => {
const result = buildPartialRegistryBase(
{
...DEFAULT_CONFIG,
font: "figtree",
},
["theme", "font"]
)
expect(result.config?.tailwind?.baseColor).toBe("neutral")
expect(result.registryDependencies).toEqual(["font-figtree"])
expect(result.cssVars?.light?.radius).toBe("0.625rem")
expect(result.cssVars?.light).toBeDefined()
expect(result.cssVars?.theme?.["--font-heading"]).toBe("var(--font-sans)")
})
})

View File

@@ -3,6 +3,7 @@ import {
type IconLibrary,
type IconLibraryName,
} from "shadcn/icons"
import { type RegistryItem } from "shadcn/schema"
import { z } from "zod"
import { BASE_COLORS, type BaseColor } from "@/registry/base-colors"
@@ -25,6 +26,8 @@ export type StyleName = Style["name"]
export type ThemeName = Theme["name"]
export type BaseColorName = BaseColor["name"]
export type ChartColorName = Theme["name"]
export const REGISTRY_BASE_PARTS = ["theme", "font"] as const
export type RegistryBasePart = (typeof REGISTRY_BASE_PARTS)[number]
// Derive font values from registry fonts (e.g., "font-inter" -> "inter").
const fontValues = bodyFonts.map((f) => f.name.replace("font-", "")) as [
@@ -98,7 +101,7 @@ export const designSystemConfigSchema = z
theme: z.enum(THEMES.map((t) => t.name) as [ThemeName, ...ThemeName[]]),
chartColor: z
.enum(THEMES.map((t) => t.name) as [ChartColorName, ...ChartColorName[]])
.default("neutral"),
.optional(),
font: z.enum(fontValues).default("inter"),
fontHeading: z.enum(fontHeadingValues).default("inherit"),
item: z.string().optional(),
@@ -136,6 +139,10 @@ export const designSystemConfigSchema = z
.default("next")
.optional(),
})
.transform((data) => ({
...data,
chartColor: data.chartColor ?? data.theme,
}))
.refine(
(data) => {
const availableThemes = getThemesForBaseColor(data.baseColor)
@@ -472,6 +479,35 @@ export function getIconLibrary(name: IconLibraryName) {
return iconLibraries[name]
}
export function parseRegistryBaseParts(value: string | null) {
if (value === null) {
return { success: true as const, parts: undefined }
}
const aliases: Record<string, RegistryBasePart> = {
theme: "theme",
font: "font",
fonts: "font",
}
const rawParts = value
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
const invalid = rawParts.filter((part) => !aliases[part])
if (!rawParts.length || invalid.length) {
return {
success: false as const,
error: `Invalid only value. Use one or more of: ${REGISTRY_BASE_PARTS.join(", ")}`,
}
}
return {
success: true as const,
parts: Array.from(new Set(rawParts.map((part) => aliases[part]))),
}
}
// Builds a registry:theme item from a design system config.
export function buildRegistryTheme(config: DesignSystemConfig) {
const baseColor = getBaseColor(config.baseColor)
@@ -610,3 +646,68 @@ export function buildRegistryBase(config: DesignSystemConfig) {
}),
}
}
export function buildPartialRegistryBase(
config: DesignSystemConfig,
parts: RegistryBasePart[]
) {
const uniqueParts = Array.from(new Set(parts))
const normalizedFontHeading =
config.fontHeading === config.font ? "inherit" : config.fontHeading
const partialConfig: {
menuColor?: DesignSystemConfig["menuColor"]
menuAccent?: DesignSystemConfig["menuAccent"]
tailwind?: {
baseColor?: string
}
} = {}
const registryDependencies: string[] = []
const cssVars: NonNullable<RegistryItem["cssVars"]> = {}
if (uniqueParts.includes("theme")) {
const registryTheme = buildRegistryTheme(config)
partialConfig.menuColor = config.menuColor
partialConfig.menuAccent = config.menuAccent
partialConfig.tailwind = {
baseColor: config.baseColor,
}
if (registryTheme.cssVars.theme) {
cssVars.theme = {
...(cssVars.theme ?? {}),
...registryTheme.cssVars.theme,
}
}
cssVars.light = {
...(cssVars.light ?? {}),
...registryTheme.cssVars.light,
}
cssVars.dark = {
...(cssVars.dark ?? {}),
...registryTheme.cssVars.dark,
}
}
if (uniqueParts.includes("font")) {
registryDependencies.push(`font-${config.font}`)
if (normalizedFontHeading !== "inherit") {
registryDependencies.push(`font-heading-${normalizedFontHeading}`)
} else {
cssVars.theme = {
...(cssVars.theme ?? {}),
"--font-heading": getInheritedHeadingFontValue(config.font),
}
}
}
return {
name: `${config.base}-${config.style}-${uniqueParts.join("-")}`,
extends: "none",
type: "registry:base" as const,
...(Object.keys(partialConfig).length > 0 && { config: partialConfig }),
...(registryDependencies.length > 0 && { registryDependencies }),
...(Object.keys(cssVars).length > 0 && { cssVars }),
} satisfies RegistryItem
}

View File

@@ -406,6 +406,13 @@
"url": "https://limeplay.winoffrg.dev/r/{name}.json",
"logo": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='248.92 245.57 243.13 238.13'><path fill='var(--foreground)' d='M397.568 369.262c27.617.078 52.447-2.298 74.398 18.495 23.415 22.179 19.46 42.881 20.087 71.256l-.338.065c-3.665-2.027-12.027-11.519-15.515-15.004l-42.2-42.3q-17.386-17.186-36.432-32.512m-23.033 23.316c2.551 2.28 4.734 5.153 6.955 7.748q5.166 6.128 10.714 11.914c4.496 4.603 9.257 8.958 13.844 13.474l33.445 32.883c3.942 3.854 23.987 22 25.442 24.88-10.187.33-20.88.323-31.06-.258-17.994-1.025-32.831-8.672-44.752-22.171-12.9-14.607-14.79-31.744-14.856-50.467-.022-5.976-.092-12.04.268-18.003m-82.968-146.672a130 130 0 0 1 9.225-.34c18.421.013 34.481 5.638 47.565 18.9 20.827 21.111 18.957 44.541 18.68 71.783-2.439-2.843-5.093-5.526-7.662-8.254-12.933-13.759-26.76-26.688-40.247-39.898-14.263-13.97-28.241-28.195-42.73-41.935q7.586-.069 15.17-.256m161.313 17.662c3.514.126 7.045-.112 10.561-.144 9.31-.087 18.68-.232 27.983.105-5.955 5.27-11.465 11.148-17.189 16.673l-42.854 41.773c-6.323 6.18-12.487 12.94-19.188 18.68-3.967.852-8.483.507-12.543.502-8.624-.01-17.272-.354-25.89-.096 7.41-8.54 15.532-16.36 23.526-24.345l31.967-31.77q6.211-6.04 12.278-12.227c2.61-2.685 5.315-5.972 8.288-8.237.836-.637 2.051-.755 3.061-.914m-203.541 6.695c9.659 10.217 19.8 19.972 29.645 30.009 15.666 15.969 31.365 32.346 48.503 46.74 3.597 3.022 12.376 8.58 14.531 12.019-13.147.069-26.618.546-39.66-1.34-17.154-2.479-30.895-11.561-41.164-25.434-10.742-14.515-12.2-30.464-12.198-47.948.001-4.588-.323-9.51.343-14.046m78.014 116.444c4.605-.31 9.327-.08 13.948-.075l25.584.02c-19.013 19.47-38.82 38.283-58.421 57.166q-6.565 6.407-12.98 12.966c-2.425 2.45-4.845 5.22-7.62 7.278-5.649.827-11.798.43-17.515.339l-21.382-.221c4.733-5.283 10.086-10.137 15.12-15.137l30.084-29.846 22.484-22.392c3.262-3.232 6.797-7.69 10.698-10.098m160.9-78.485c1.14-.04 2.042-.036 2.952.684 1.386 5.214.558 14.208.538 19.74l-.074 32.96c-17.807-.794-35.786-.642-53.613-.752-2.584-.04-5.187.15-7.77.27a70 70 0 0 0 8.311-2.594c16.945-6.515 32.422-18.069 41.772-33.825 3.158-5.322 5.403-10.85 7.884-16.483M249.32 365.341q29.561-.259 59.121.02c-12.883 4.064-25.515 10.66-35.128 20.269-5.107 4.798-9.766 11.471-13.043 17.616-3.724 6.982-5.44 11.562-10.934 17.663-.274-18.506.001-37.057-.016-55.568m120.035 57.99c1.633 2.12 1.007 53.792 1.009 60.112l-56.072-.041-.742-.183c1.048-1.815 7.844-3.553 10.03-4.529 16.73-7.47 31.393-21.39 39.24-37.922 2.7-5.69 4.321-11.586 6.535-17.437m40.048-177.475c5.68.438 11.456.419 17.153.505q-6.27 1.47-12.115 4.169c-19.414 8.896-31.57 24.434-38.786 44.132q-1.86 5.667-3.206 11.478c-.51-3.507-1.266-6.936-1.606-10.474-1.576-16.383-.933-33.11-.803-49.551l22.7-.069c5.539.01 11.137.202 16.663-.19'/></svg>"
},
{
"name": "@loading-ui",
"homepage": "https://loading-ui.com",
"url": "https://loading-ui.com/r/{name}.json",
"description": "Spinners, loaders, and loading animations for modern web apps. Free and open-source.",
"logo": "<svg viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M17.0713 5.75875C16.2913 6.53875 15.0212 6.53875 14.2412 5.75875C13.4613 4.97875 13.4613 3.70875 14.2412 2.92875C15.0212 2.14875 16.2913 2.14875 17.0713 2.92875C17.8513 3.70875 17.8513 4.97875 17.0713 5.75875Z' fill='var(--foreground)' fill-opacity='0.3'/><path d='M14.2412 17.0713C13.4612 16.2913 13.4612 15.0212 14.2412 14.2412C15.0212 13.4613 16.2913 13.4613 17.0713 14.2412C17.8512 15.0212 17.8512 16.2913 17.0713 17.0713C16.2913 17.8513 15.0212 17.8513 14.2412 17.0713Z' fill='var(--foreground)' fill-opacity='0.5'/><path d='M10 0C9.46957 0 8.96086 0.210714 8.58579 0.585786C8.21071 0.960859 8 1.46957 8 2C8 2.53043 8.21071 3.03914 8.58579 3.41421C8.96086 3.78929 9.46957 4 10 4C10.5304 4 11.0391 3.78929 11.4142 3.41421C11.7893 3.03914 12 2.53043 12 2C12 1.46957 11.7893 0.960859 11.4142 0.585786C11.0391 0.210714 10.5304 0 10 0Z' fill='var(--foreground)'/><path d='M20 10C20 9.46957 19.7893 8.96086 19.4142 8.58579C19.0391 8.21071 18.5304 8 18 8C17.4696 8 16.9609 8.21071 16.5858 8.58579C16.2107 8.96086 16 9.46957 16 10C16 10.5304 16.2107 11.0391 16.5858 11.4142C16.9609 11.7893 17.4696 12 18 12C18.5304 12 19.0391 11.7893 19.4142 11.4142C19.7893 11.0391 20 10.5304 20 10Z' fill='var(--foreground)' fill-opacity='0.4'/><path d='M5.75875 2.92875C6.53875 3.70875 6.53875 4.97875 5.75875 5.75875C4.97875 6.53875 3.70875 6.53875 2.92875 5.75875C2.14875 4.97875 2.14875 3.70875 2.92875 2.92875C3.70875 2.14875 4.97875 2.14875 5.75875 2.92875Z' fill='var(--foreground)' fill-opacity='0.9'/><path d='M0 10C0 10.5304 0.210714 11.0391 0.585786 11.4142C0.960859 11.7893 1.46957 12 2 12C2.53043 12 3.03914 11.7893 3.41421 11.4142C3.78929 11.0391 4 10.5304 4 10C4 9.46957 3.78929 8.96086 3.41421 8.58579C3.03914 8.21071 2.53043 8 2 8C1.46957 8 0.960859 8.21071 0.585786 8.58579C0.210714 8.96086 0 9.46957 0 10Z' fill='var(--foreground)' fill-opacity='0.8'/><path d='M2.92875 14.2412C3.70875 13.4612 4.97875 13.4612 5.75875 14.2412C6.53875 15.0212 6.53875 16.2913 5.75875 17.0713C4.97875 17.8512 3.70875 17.8512 2.92875 17.0713C2.14875 16.2913 2.14875 15.0212 2.92875 14.2412Z' fill='var(--foreground)' fill-opacity='0.7'/><path d='M10 20C10.5304 20 11.0391 19.7893 11.4142 19.4142C11.7893 19.0391 12 18.5304 12 18C12 17.4696 11.7893 16.9609 11.4142 16.5858C11.0391 16.2107 10.5304 16 10 16C9.46957 16 8.96086 16.2107 8.58579 16.5858C8.21071 16.9609 8 17.4696 8 18C8 18.5304 8.21071 19.0391 8.58579 19.4142C8.96086 19.7893 9.46957 20 10 20Z' fill='var(--foreground)' fill-opacity='0.6'/></svg>"
},
{
"name": "@lmscn",
"homepage": "https://lmscn.vercel.app",
@@ -1203,5 +1210,26 @@
"url": "https://www.remocn.dev/r/{name}.json",
"description": "Production-ready components for Remotion - text animations, backgrounds, transitions, UI blocks, and full scene compositions",
"logo": "<svg width='100' height='100' viewBox='0 0 100 100' fill='none' xmlns='http://www.w3.org/2000/svg'><rect width='100' height='100' rx='14' fill='black'/><path d='M83.9141 45.7661C86.9141 47.4982 86.9141 51.829 83.9141 53.561L31.4141 83.8716C28.4141 85.6036 24.6641 83.4382 24.6641 79.9741L24.6641 19.353C24.6641 15.8889 28.4141 13.7235 31.4141 15.4556L83.9141 45.7661Z' fill='white' stroke='white'/><path d='M68.998 31.3979C68.998 50.3798 68.998 63.307 68.998 67.3979' stroke='black' stroke-width='10'/><path d='M53.0625 16.75C53.0625 48.2803 53.0625 69.7535 53.0625 76.5488' stroke='black' stroke-width='10'/><path d='M37.127 9.88379C37.127 51.1081 37.127 79.1833 37.127 88.0679' stroke='black' stroke-width='10'/><path d='M31.1318 15.8618L43.1074 22.7646' stroke='white' stroke-width='2'/><path d='M63.291 34.439L75.2617 41.3579' stroke='white' stroke-width='2'/><path d='M46.6641 74.4712L59.208 67.2573' stroke='white' stroke-width='2'/><path d='M76.0205 57.5371L82.9848 53.5226' stroke='white' stroke-width='2'/></svg>"
},
{
"name": "@exabase",
"homepage": "https://exawizards.com/exabase/design/",
"url": "https://exawizards.com/exabase/design/registry/{name}.json",
"description": "A collection of UI components based on the exaBase Design System, built with React and Tailwind CSS.",
"logo": "<svg width='189' height='287' viewBox='0 0 189 287' fill='#1400C8'><path d='M15.077 103.074L14.579 104.228C-4.78838 149.063 -4.78838 152.305 14.579 197.167C21.5789 213.431 47.7538 274.025 47.7538 274.025L48.2518 272.872C67.6192 228.036 67.6192 224.795 48.2518 179.959C41.2519 163.696 15.077 103.074 15.077 103.074Z'></path><path d='M53.0599 0.607147L52.6725 1.76099C33.3052 46.5962 33.3052 49.8379 52.6725 94.7006L135.288 286.321L135.786 285.168C155.154 240.332 155.154 237.091 135.786 192.256L53.0599 0.607147Z'></path><path d='M140.594 12.9031L140.096 14.057C120.729 58.8922 120.729 62.1339 140.096 106.997C147.096 123.26 173.271 183.855 173.271 183.855L173.769 182.701C193.137 137.866 193.137 134.624 173.769 89.7889C166.769 73.5252 140.594 12.9031 140.594 12.9031Z'></path></svg>"
},
{
"name": "@aicanvas",
"homepage": "https://aicanvas.me",
"url": "https://aicanvas.me/r/{name}.json",
"description": "54 animated React components with AI reproduction prompts for Claude Code, Lovable, and v0. Free and open source.",
"logo": "<svg width=\"28\" height=\"24\" viewBox=\"0 0 28 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M19.8513 0C20.5626 0 21.2204 0.377823 21.5788 0.992258L22.75 3L26.8243 9.98452C27.5508 11.23 27.5508 12.77 26.8243 14.0155L22.75 21L21.5788 23.0077C21.2204 23.6222 20.5626 24 19.8513 24H8.14874C7.43741 24 6.7796 23.6222 6.42118 23.0077L0.587849 13.0077C0.224593 12.385 0.224593 11.615 0.58785 10.9923L6.42118 0.992257C6.7796 0.377822 7.43741 0 8.14874 0H19.8513ZM4 12L9 21H18.25L13 12L18.25 3H9L4 12Z\" fill=\"url(#paint0_linear_185_248)\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M9 21L4 12H13L18.25 21H9Z\" fill=\"#1E1E1E\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M13 12H4L9 3H18.25L13 12Z\" fill=\"#4F4F4C\"/><defs><linearGradient id=\"paint0_linear_185_248\" x1=\"9\" y1=\"3\" x2=\"24.8756\" y2=\"17\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#666662\"/><stop offset=\"1\" stop-color=\"#4F4F4C\"/></linearGradient></defs></svg>"
},
{
"name": "@xcn",
"homepage": "http://ui.radiumcoders.com",
"url": "https://ui-layouts.com/r/xcn/{name}.json",
"description": "Hand-crafted, beautiful, and minimal UI components built with Tailwind CSS and Motion.",
"logo": "<svg width=\"270\" height=\"255\" viewBox=\"0 0 270 255\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M67.5004 3.08656C36.493 20.9887 13.8672 50.4752 4.60036 85.0594C-4.66646 119.644 0.184795 156.493 18.0869 187.5C35.989 218.507 65.4755 241.133 100.06 250.4C134.644 259.667 171.493 254.816 202.5 236.913L183.833 204.581C161.401 217.532 134.743 221.042 109.723 214.338C84.7028 207.634 63.3708 191.265 50.4196 168.833C37.4683 146.4 33.9587 119.742 40.6628 94.7223C47.3668 69.7024 63.7354 48.3705 86.1676 35.4192L67.5004 3.08656Z\" fill=\"#0A0A0A\"/><path d=\"M94.6007 49.785C76.0424 60.4599 62.5006 78.0426 56.9543 98.6651C51.4079 119.287 54.3115 141.26 65.0261 159.75C75.7408 178.24 93.3888 191.731 114.088 197.257C134.787 202.783 156.842 199.89 175.4 189.215L160.015 162.667C148.524 169.276 134.868 171.068 122.052 167.646C109.235 164.225 98.3076 155.871 91.6732 144.422C85.0388 132.974 83.241 119.368 86.6752 106.599C90.1094 93.8301 98.4943 82.9431 109.985 76.3333L94.6007 49.785Z\" fill=\"#0A0A0A\"/><path d=\"M165.614 120C165.614 136.569 152.132 150 135.502 150C118.872 150 105.391 136.569 105.391 120C105.391 103.431 118.872 90 135.502 90C152.132 90 165.614 103.431 165.614 120ZM117.419 120C117.419 129.95 125.515 113 135.502 113C145.489 113 153.585 129.95 153.585 120C153.585 110.05 145.489 101.984 135.502 101.984C125.515 101.984 117.419 110.05 117.419 120Z\" fill=\"#0A0A0A\"/></svg>"
}
]

View File

@@ -1,8 +1,10 @@
import { spawn } from "child_process"
import { createHash } from "crypto"
import { promises as fs } from "fs"
import { createRequire } from "module"
import { availableParallelism } from "os"
import path from "path"
import { fileURLToPath } from "url"
import prettier from "prettier"
import { rimraf } from "rimraf"
import { registrySchema, type RegistryItem } from "shadcn/schema"
@@ -72,7 +74,7 @@ const CLI_BUILD_CONCURRENCY = Math.max(
1,
Math.min(Math.floor(CPU_COUNT / 2), 4)
)
const TRANSFORM_CACHE_VERSION = "2"
const TRANSFORM_CACHE_VERSION = "3"
const CACHE_ROOT = path.join(
process.cwd(),
"node_modules/.cache/build-registry"
@@ -82,10 +84,21 @@ const TRANSFORM_CACHE_MANIFEST_PATH = path.join(
CACHE_ROOT,
"transform-manifest.json"
)
const GENERATED_REGISTRY_CACHE_PATHS = new Set([
"registry/__blocks__.json",
"registry/__index__.tsx",
"registry/bases/__index__.tsx",
])
const transformCacheManifest = new Map<string, string>()
type TransformCacheManifestEntry = {
inputHash: string
outputHash: string
}
const transformCacheManifest = new Map<string, TransformCacheManifestEntry>()
let transformCacheDirty = false
let prettierConfigPromise: Promise<prettier.Options | null> | null = null
const resolveFromScript = createRequire(import.meta.url).resolve
const iconProject = new Project({
compilerOptions: {},
@@ -153,6 +166,88 @@ function hashContent(...parts: string[]) {
return hash.digest("hex")
}
async function getTransformCacheHash() {
const [implementationHash, registryHash] = await Promise.all([
getTransformImplementationHash(),
getAuthoredRegistryHash(),
])
return hashContent(implementationHash, registryHash)
}
async function getTransformImplementationHash() {
const dependencyFiles = [
fileURLToPath(import.meta.url),
resolveFromScript("shadcn/utils"),
path.resolve(process.cwd(), "../../pnpm-lock.yaml"),
]
const dependencyContent = await Promise.all(
dependencyFiles.map(async (filePath) => {
const content = await readFileIfExists(filePath)
const relativePath = toPosixPath(path.relative(process.cwd(), filePath))
return `${relativePath}\0${content ?? "missing"}`
})
)
return hashContent(...dependencyContent)
}
async function getAuthoredRegistryHash() {
const registryRoot = path.join(process.cwd(), "registry")
const filePaths = await getCacheableRegistryFiles(registryRoot)
const fileContent = await Promise.all(
filePaths.map(async (filePath) => {
const relativePath = toPosixPath(path.relative(process.cwd(), filePath))
const content = await fs.readFile(filePath, "utf8")
return `${relativePath}\0${content}`
})
)
return hashContent(...fileContent)
}
async function getCacheableRegistryFiles(dirPath: string): Promise<string[]> {
const entries = await readDirectoryEntries(dirPath)
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dirPath, entry.name)
const relativePath = toPosixPath(path.relative(process.cwd(), entryPath))
if (shouldSkipRegistryCachePath(relativePath)) {
return []
}
if (entry.isDirectory()) {
return getCacheableRegistryFiles(entryPath)
}
if (!entry.isFile()) {
return []
}
return [entryPath]
})
)
return files.flat().sort((a, b) => a.localeCompare(b))
}
function shouldSkipRegistryCachePath(relativePath: string) {
if (GENERATED_REGISTRY_CACHE_PATHS.has(relativePath)) {
return true
}
return STYLE_COMBINATIONS.some((style) =>
relativePath.startsWith(`registry/${style.name}/`)
)
}
function toPosixPath(filePath: string) {
return filePath.split(path.sep).join("/")
}
async function readFileIfExists(filePath: string) {
try {
return await fs.readFile(filePath, "utf8")
@@ -200,13 +295,28 @@ async function loadTransformCache() {
return
}
const payload = JSON.parse(existingManifest) as Record<string, string>
const payload = JSON.parse(existingManifest) as Record<string, unknown>
for (const [key, value] of Object.entries(payload)) {
transformCacheManifest.set(key, value)
if (isTransformCacheManifestEntry(value)) {
transformCacheManifest.set(key, value)
}
}
}
function isTransformCacheManifestEntry(
value: unknown
): value is TransformCacheManifestEntry {
return (
typeof value === "object" &&
value !== null &&
"inputHash" in value &&
"outputHash" in value &&
typeof value.inputHash === "string" &&
typeof value.outputHash === "string"
)
}
async function saveTransformCache() {
if (!transformCacheDirty) {
return
@@ -234,6 +344,7 @@ async function getCachedStyledContent({
filePath,
source,
styleHash,
transformCacheHash,
styleMap,
}: {
styleName: string
@@ -241,6 +352,7 @@ async function getCachedStyledContent({
filePath: string
source: string
styleHash: string
transformCacheHash: string
styleMap: Record<string, string>
}) {
const cacheKey = `${styleName}:${filePath}`
@@ -250,13 +362,18 @@ async function getCachedStyledContent({
styleName,
baseName,
filePath,
transformCacheHash,
styleHash,
source
)
if (transformCacheManifest.get(cacheKey) === inputHash) {
const cachedEntry = transformCacheManifest.get(cacheKey)
if (cachedEntry?.inputHash === inputHash) {
const cachedContent = await readFileIfExists(cachePath)
if (cachedContent !== null) {
if (
cachedContent !== null &&
hashContent(cachedContent) === cachedEntry.outputHash
) {
return cachedContent
}
}
@@ -274,8 +391,13 @@ async function getCachedStyledContent({
await fs.mkdir(path.dirname(cachePath), { recursive: true })
await fs.writeFile(cachePath, transformedContent)
if (transformCacheManifest.get(cacheKey) !== inputHash) {
transformCacheManifest.set(cacheKey, inputHash)
const outputHash = hashContent(transformedContent)
const nextEntry = { inputHash, outputHash }
if (
cachedEntry?.inputHash !== nextEntry.inputHash ||
cachedEntry?.outputHash !== nextEntry.outputHash
) {
transformCacheManifest.set(cacheKey, nextEntry)
transformCacheDirty = true
}
@@ -457,7 +579,7 @@ export const Index: Record<string, Record<string, any>> = {`
}
async function buildBases(bases: Base[]) {
const [baseImports, styleMaps] = await Promise.all([
const [baseImports, styleMaps, transformCacheHash] = await Promise.all([
Promise.all(
bases.map(async (base) => {
const { registry: baseRegistry } = await import(
@@ -516,6 +638,7 @@ async function buildBases(bases: Base[]) {
}
})
),
getTransformCacheHash(),
])
const combinations: Array<{
@@ -525,6 +648,7 @@ async function buildBases(bases: Base[]) {
registryItems: (typeof baseImports)[number]["registryItems"]
sourceFiles: (typeof baseImports)[number]["sourceFiles"]
styleHash: string
transformCacheHash: string
styleMap: Record<string, string>
}> = []
@@ -542,6 +666,7 @@ async function buildBases(bases: Base[]) {
registryItems,
sourceFiles,
styleHash,
transformCacheHash,
styleMap,
})
}
@@ -557,6 +682,7 @@ async function buildBases(bases: Base[]) {
registryItems,
sourceFiles,
styleHash,
transformCacheHash,
styleMap,
}) => {
const styleName = `${base.name}-${style.name}`
@@ -597,6 +723,7 @@ async function buildBases(bases: Base[]) {
filePath: file.path,
source,
styleHash,
transformCacheHash,
styleMap,
})
: source

View File

@@ -1,5 +1,23 @@
# @shadcn/ui
## 4.4.0
### Minor Changes
- [#10451](https://github.com/shadcn-ui/ui/pull/10451) [`e456fed9d3f0b7aacf7084aecc02a75e8fde622d`](https://github.com/shadcn-ui/ui/commit/e456fed9d3f0b7aacf7084aecc02a75e8fde622d) Thanks [@shadcn](https://github.com/shadcn)! - add apply --only
### Patch Changes
- [`9c572ab778b5a0ab42693eb07bc4a75d0c24603e`](https://github.com/shadcn-ui/ui/commit/9c572ab778b5a0ab42693eb07bc4a75d0c24603e) Thanks [@shadcn](https://github.com/shadcn)! - fix chartColor in presets
## 4.3.1
### Patch Changes
- [#10436](https://github.com/shadcn-ui/ui/pull/10436) [`b7cfc364aca36bc90f8efa86773bc81011502036`](https://github.com/shadcn-ui/ui/commit/b7cfc364aca36bc90f8efa86773bc81011502036) Thanks [@shadcn](https://github.com/shadcn)! - Ensure `init` only runs template post-init hooks for newly created projects.
- [#10179](https://github.com/shadcn-ui/ui/pull/10179) [`d00605c5fb5fe3cfbcb68cea65398430cdd819f8`](https://github.com/shadcn-ui/ui/commit/d00605c5fb5fe3cfbcb68cea65398430cdd819f8) Thanks [@EthanThatOneKid](https://github.com/EthanThatOneKid)! - Send `Accept: application/vnd.shadcn.v1+json, application/json;q=0.9` and `User-Agent: shadcn` on registry fetches so servers using HTTP content negotiation can reliably serve JSON to the CLI. Fixes #10164.
## 4.3.0
### Minor Changes

View File

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

View File

@@ -1,7 +1,13 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import { resolveApplyInitUrl } from "./apply"
import {
getPresetUrlOnly,
parseApplyOnlyParts,
resolveApplyInitUrl,
resolveApplyOnly,
validateApplyOnlyPreset,
} from "./apply"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
@@ -46,4 +52,128 @@ describe("resolveApplyInitUrl", () => {
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include only for preset codes", () => {
const initUrl = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
only: "theme,font",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("theme,font")
})
it("should include only for named presets", () => {
const initUrl = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
only: "font",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("font")
})
it("should include only for raw preset URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
only: "theme",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("theme")
})
it("should preserve only from raw preset URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default&only=font`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("font")
})
})
describe("parseApplyOnlyParts", () => {
it("returns undefined when only is omitted", () => {
expect(resolveApplyOnly(undefined)).toBeUndefined()
})
it("rejects missing only values with allowed values", () => {
expect(() => resolveApplyOnly(true)).toThrow(
[
"Missing value for --only.",
"Use one or more of: theme, font.",
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
})
it("normalizes explicit values and aliases", () => {
expect(resolveApplyOnly("font")).toEqual(["font"])
expect(parseApplyOnlyParts("theme,font")).toEqual(["theme", "font"])
})
it("dedupes and accepts plural aliases", () => {
expect(parseApplyOnlyParts("theme,fonts,font")).toEqual(["theme", "font"])
})
it("rejects invalid values", () => {
expect(() => parseApplyOnlyParts("theme,colors")).toThrow(
[
"Invalid value for --only: theme,colors.",
"Use one or more of: theme, font.",
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
expect(() => parseApplyOnlyParts("")).toThrow("Invalid value for --only")
expect(() => parseApplyOnlyParts("icon")).toThrow(
"Use one or more of: theme, font."
)
})
})
describe("getPresetUrlOnly", () => {
it("reads only from shadcn init URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&only=font`
expect(getPresetUrlOnly(presetUrl)).toBe("font")
})
it("reads only from non-shadcn init URLs", () => {
const presetUrl =
"http://localhost:4000/init?base=radix&style=nova&only=font"
expect(getPresetUrlOnly(presetUrl)).toBe("font")
expect(resolveApplyOnly(getPresetUrlOnly(presetUrl))).toEqual(["font"])
})
it("ignores only on non-init URLs", () => {
const presetUrl =
"http://localhost:4000/r/styles/nova/button.json?only=font"
expect(getPresetUrlOnly(presetUrl)).toBeUndefined()
})
})
describe("validateApplyOnlyPreset", () => {
it("rejects only without a preset", () => {
expect(() => validateApplyOnlyPreset({ only: ["theme"] })).toThrow(
[
"Missing preset for --only.",
"Use: shadcn apply <preset> --only theme,font.",
].join("\n")
)
})
it("allows only with a preset", () => {
expect(() =>
validateApplyOnlyPreset({ preset: "a0", only: ["theme"] })
).not.toThrow()
})
})

View File

@@ -33,15 +33,27 @@ export const applyOptionsSchema = z.object({
cwd: z.string(),
positionalPreset: z.string().optional(),
preset: z.string().optional(),
only: z.union([z.boolean(), z.string()]).optional(),
yes: z.boolean(),
silent: z.boolean(),
})
const APPLY_ONLY_VALUES = ["theme", "font"] as const
type ApplyOnlyValue = (typeof APPLY_ONLY_VALUES)[number]
class ApplyOnlyError extends Error {
constructor(message: string) {
super(message)
this.name = "ApplyOnlyError"
}
}
export const apply = new Command()
.name("apply")
.description("apply a preset to an existing project")
.argument("[preset]", "the preset to apply")
.option("--preset <preset>", "preset configuration to apply")
.option("--only [parts]", "apply only parts of a preset: theme, font")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
@@ -58,6 +70,8 @@ export const apply = new Command()
})
const preset = resolveApplyPreset(options)
const explicitOnly = resolveApplyOnly(options.only)
validateApplyOnlyPreset({ preset, only: explicitOnly })
const preflight = await preFlightApply(options)
@@ -114,26 +128,43 @@ export const apply = new Command()
validatePreset(preset)
const reinstallComponents = await getProjectComponents(options.cwd)
const only = explicitOnly ?? resolveApplyOnly(getPresetUrlOnly(preset))
const shouldReinstallComponents = !only
const reinstallComponents = shouldReinstallComponents
? await getProjectComponents(options.cwd)
: []
if (!options.yes) {
logger.break()
logger.warn(
highlighter.warn(
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
if (!only) {
logger.warn(
highlighter.warn(
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
)
)
)
} else {
logger.warn(
highlighter.warn(
`Applying the selected preset parts will update your project configuration and styles.`
)
)
}
logger.warn(
`Commit or stash your changes before continuing so you can easily go back.`
)
logger.break()
logger.log(" The following components will be re-installed:")
if (reinstallComponents.length) {
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(` - ${reinstallComponents.slice(i, i + 8).join(", ")}`)
if (shouldReinstallComponents) {
logger.break()
logger.log(" The following components will be re-installed:")
if (reinstallComponents.length) {
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(
` - ${reinstallComponents.slice(i, i + 8).join(", ")}`
)
}
} else {
logger.log(" - No installed UI components were detected.")
}
} else {
logger.log(" - No installed UI components were detected.")
}
logger.break()
@@ -156,6 +187,7 @@ export const apply = new Command()
const initUrl = resolveApplyInitUrl(preset, currentBase, {
template,
rtl,
only: only?.join(","),
})
await withFileBackup(
@@ -171,17 +203,23 @@ export const apply = new Command()
| undefined,
})
const applyRegistryBaseConfig = resolveApplyRegistryBaseConfig({
registryBaseConfig,
existingConfig,
only,
})
await runInit({
cwd: options.cwd,
yes: true,
force: false,
reinstall: true,
reinstall: shouldReinstallComponents,
defaults: false,
silent: options.silent,
isNewProject: false,
cssVariables: true,
installStyleIndex,
registryBaseConfig,
registryBaseConfig: applyRegistryBaseConfig,
existingConfig,
components: [cleanUrl, ...reinstallComponents],
})
@@ -201,6 +239,14 @@ export const apply = new Command()
logger.log("Preset applied successfully.")
logger.break()
} catch (error) {
if (error instanceof ApplyOnlyError) {
for (const line of error.message.split("\n")) {
logger.error(line)
}
logger.break()
process.exit(1)
}
logger.break()
handleError(error)
} finally {
@@ -225,6 +271,118 @@ function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
return flagPreset ?? positionalPreset
}
export function getPresetUrlOnly(preset: string) {
if (!isUrl(preset)) {
return undefined
}
const url = new URL(preset)
if (url.pathname !== "/init") {
return undefined
}
return url.searchParams.get("only") ?? undefined
}
export function resolveApplyOnly(
value: z.infer<typeof applyOptionsSchema>["only"]
) {
if (value === undefined || value === false) {
return undefined
}
if (value === true) {
throw new ApplyOnlyError(
[
"Missing value for --only.",
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
return parseApplyOnlyParts(value)
}
export function parseApplyOnlyParts(value: string) {
const aliases: Record<string, ApplyOnlyValue> = {
theme: "theme",
font: "font",
fonts: "font",
}
const parts = value
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
const invalid = parts.filter((part) => !aliases[part])
if (!parts.length || invalid.length) {
throw new ApplyOnlyError(
[
`Invalid value for --only: ${value}.`,
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
return Array.from(new Set(parts.map((part) => aliases[part])))
}
export function validateApplyOnlyPreset(options: {
preset?: string
only?: ApplyOnlyValue[]
}) {
if (!options.only || options.preset) {
return
}
throw new ApplyOnlyError(
[
"Missing preset for --only.",
"Use: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
function resolveApplyRegistryBaseConfig(options: {
registryBaseConfig: Record<string, unknown> | undefined
existingConfig: Record<string, unknown>
only: ApplyOnlyValue[] | undefined
}) {
if (!options.only || options.only.includes("theme")) {
return options.registryBaseConfig
}
const existingTailwind =
typeof options.existingConfig.tailwind === "object" &&
options.existingConfig.tailwind !== null
? options.existingConfig.tailwind
: {}
const registryTailwind =
typeof options.registryBaseConfig?.tailwind === "object" &&
options.registryBaseConfig.tailwind !== null
? options.registryBaseConfig.tailwind
: {}
const config: Record<string, unknown> = {
...options.registryBaseConfig,
tailwind: {
...existingTailwind,
...registryTailwind,
},
}
if (options.existingConfig.menuColor) {
config.menuColor = options.existingConfig.menuColor
}
if (options.existingConfig.menuAccent) {
config.menuAccent = options.existingConfig.menuAccent
}
return config
}
function validatePreset(preset: string) {
if (isUrl(preset) || isPresetCode(preset)) {
return
@@ -251,7 +409,7 @@ async function resolveApplyTemplate(cwd: string) {
export function resolveApplyInitUrl(
preset: string,
currentBase: "radix" | "base",
options: { template?: string; rtl?: boolean } = {}
options: { template?: string; rtl?: boolean; only?: string } = {}
) {
if (isUrl(preset)) {
const url = new URL(preset)
@@ -262,6 +420,9 @@ export function resolveApplyInitUrl(
url.searchParams.set("base", currentBase)
url.searchParams.set("rtl", String(options.rtl ?? false))
if (options.only) {
url.searchParams.set("only", options.only)
}
return url.toString()
}
@@ -280,7 +441,7 @@ export function resolveApplyInitUrl(
base: currentBase,
rtl: options.rtl ?? false,
},
{ preset, template: options.template }
{ preset, template: options.template, only: options.only }
)
}
@@ -292,7 +453,7 @@ export function resolveApplyInitUrl(
base: currentBase,
rtl: options.rtl ?? resolvedPreset.rtl,
},
{ template: options.template }
{ template: options.template, only: options.only }
)
}

View File

@@ -0,0 +1,254 @@
import { mkdir, mkdtemp, rm } from "fs/promises"
import os from "os"
import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { templates } from "@/src/templates"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { MISSING_DIR_OR_EMPTY_PROJECT } from "@/src/utils/errors"
import {
getProjectConfig,
getProjectInfo,
getProjectTailwindVersionFromConfig,
} from "@/src/utils/get-project-info"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { runInit } from "./init"
vi.mock("@/src/preflights/preflight-init", () => ({
preFlightInit: vi.fn(),
}))
vi.mock("@/src/utils/create-project", () => ({
createProject: vi.fn(),
}))
vi.mock("@/src/utils/add-components", () => ({
addComponents: vi.fn(),
}))
vi.mock("@/src/utils/registries", () => ({
ensureRegistriesInConfig: vi.fn(),
}))
vi.mock("@/src/registry/api", () => ({
getRegistryBaseColors: vi.fn().mockResolvedValue([
{
label: "Zinc",
name: "zinc",
},
]),
getRegistryStyles: vi.fn().mockResolvedValue([
{
label: "New York",
name: "new-york",
},
]),
}))
vi.mock("@/src/utils/get-config", () => ({
DEFAULT_COMPONENTS: "@/components",
DEFAULT_TAILWIND_CONFIG: "tailwind.config.js",
DEFAULT_TAILWIND_CSS: "app/globals.css",
DEFAULT_UTILS: "@/lib/utils",
explorer: {
clearCaches: vi.fn(),
},
getConfig: vi.fn(),
getWorkspaceConfig: vi.fn().mockResolvedValue(null),
resolveConfigPaths: vi.fn(
async (cwd: string, config: Record<string, unknown>) => ({
...config,
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.js"),
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
})
),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectComponents: vi.fn().mockResolvedValue([]),
getProjectConfig: vi.fn(),
getProjectInfo: vi.fn(),
getProjectTailwindVersionFromConfig: vi.fn(),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
success: (value: string) => value,
warn: (value: string) => value,
},
}))
vi.mock("prompts", () => ({
default: vi.fn(),
}))
const projectInfo = {
framework: {
label: "Vite",
links: {},
name: "vite",
},
frameworkVersion: null,
isRSC: false,
isSrcDir: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
aliasPrefix: "@",
}
function createProjectConfig(cwd: string) {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
rtl: false,
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
}
}
function createInitOptions(cwd: string) {
return {
cwd,
yes: true,
defaults: true,
force: false,
reinstall: false,
silent: true,
isNewProject: false,
cssVariables: true,
installStyleIndex: true,
template: "vite",
} as Parameters<typeof runInit>[0]
}
describe("runInit", () => {
let cwd: string
let originalPostInit: typeof templates.vite.postInit
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "shadcn-init-test-"))
originalPostInit = templates.vite.postInit
vi.mocked(getProjectInfo).mockResolvedValue(projectInfo as any)
vi.mocked(getProjectTailwindVersionFromConfig).mockResolvedValue("v4")
vi.mocked(getProjectConfig).mockImplementation(async (projectCwd) =>
createProjectConfig(projectCwd)
)
vi.mocked(ensureRegistriesInConfig).mockImplementation(
async (_components, config) => ({ config, newRegistries: [] })
)
vi.mocked(addComponents).mockResolvedValue(undefined)
})
afterEach(async () => {
templates.vite.postInit = originalPostInit
vi.clearAllMocks()
await rm(cwd, { recursive: true, force: true })
})
it("does not run template postInit for existing projects with an explicit template", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {},
projectInfo: projectInfo as any,
})
await runInit(createInitOptions(cwd))
expect(postInit).not.toHaveBeenCalled()
})
it("runs template postInit after creating a new project", async () => {
const projectPath = path.join(cwd, "vite-app")
await mkdir(projectPath)
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {
[MISSING_DIR_OR_EMPTY_PROJECT]: true,
},
projectInfo: null,
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "vite-app",
template: "vite",
})
await runInit(createInitOptions(cwd))
expect(postInit).toHaveBeenCalledWith({ projectPath })
})
it("does not run template postInit when isNewProject is true but createProject was skipped", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
await runInit({
...createInitOptions(cwd),
skipPreflight: true,
isNewProject: true,
})
expect(createProject).not.toHaveBeenCalled()
expect(postInit).not.toHaveBeenCalled()
})
})

View File

@@ -606,6 +606,8 @@ export async function runInit(
projectInfo = await getProjectInfo(options.cwd)
}
const didCreateProject = Boolean(newProjectTemplate)
// Use the template from project creation if available,
// or fall back to the explicit --template flag.
const templateKey = newProjectTemplate ?? explicitTemplate
@@ -632,8 +634,10 @@ export async function runInit(
silent: options.silent,
})
// Run postInit for new projects (e.g. git init).
await selectedTemplate.postInit({ projectPath: options.cwd })
// Run postInit only for newly scaffolded projects (e.g. git init).
if (didCreateProject) {
await selectedTemplate.postInit({ projectPath: options.cwd })
}
return result
}
@@ -770,8 +774,8 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
// Run postInit for new projects without a custom init (e.g. git init).
if (selectedTemplate) {
// Run postInit for newly scaffolded projects without a custom init (e.g. git init).
if (selectedTemplate && didCreateProject) {
await selectedTemplate.postInit({ projectPath: options.cwd })
}

View File

@@ -1,7 +1,7 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import { resolveCreateUrl, resolveInitUrl } from "./presets"
import { DEFAULT_PRESETS, resolveCreateUrl, resolveInitUrl } from "./presets"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
@@ -79,6 +79,12 @@ describe("buildInitUrl", () => {
expect(parsed.searchParams.get("chartColor")).toBe("emerald")
})
it("should include chartColor from default presets", () => {
const url = resolveInitUrl({ ...DEFAULT_PRESETS.sera, base: "base" })
const parsed = new URL(url)
expect(parsed.searchParams.get("chartColor")).toBe("taupe")
})
it("should not include chartColor when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)

View File

@@ -19,6 +19,7 @@ export const DEFAULT_PRESETS = {
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "geist",
fontHeading: "inherit",
@@ -34,6 +35,7 @@ export const DEFAULT_PRESETS = {
style: "vega",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
@@ -49,6 +51,7 @@ export const DEFAULT_PRESETS = {
style: "maia",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "figtree",
fontHeading: "inherit",
@@ -64,6 +67,7 @@ export const DEFAULT_PRESETS = {
style: "lyra",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "phosphor",
font: "jetbrains-mono",
fontHeading: "inherit",
@@ -79,6 +83,7 @@ export const DEFAULT_PRESETS = {
style: "mira",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "inter",
fontHeading: "inherit",
@@ -94,6 +99,7 @@ export const DEFAULT_PRESETS = {
style: "luma",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
@@ -109,6 +115,7 @@ export const DEFAULT_PRESETS = {
style: "sera",
baseColor: "taupe",
theme: "taupe",
chartColor: "taupe",
iconLibrary: "lucide",
font: "noto-sans",
fontHeading: "playfair-display",
@@ -188,7 +195,7 @@ export function resolveInitUrl(
menuColor: string
radius: string
},
options?: { template?: string; preset?: string }
options?: { template?: string; preset?: string; only?: string }
) {
const params = new URLSearchParams({
base: preset.base,
@@ -221,6 +228,10 @@ export function resolveInitUrl(
params.set("template", options.template)
}
if (options?.only) {
params.set("only", options.only)
}
// Signal the server to record this init run.
params.set("track", "1")

View File

@@ -10,6 +10,7 @@ import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
import { clearRegistryContext, setRegistryHeaders } from "./context"
import { clearRegistryCache, fetchRegistry } from "./fetcher"
const server = setupServer(
@@ -204,6 +205,134 @@ describe("fetchRegistry", () => {
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
it("should send specific Accept and User-Agent headers", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(`${REGISTRY_URL}/header-test.json`, ({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "header-test",
type: "registry:ui",
})
})
)
await fetchRegistry(["header-test.json"], { useCache: false })
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
expect(userAgentHeader).toBe("shadcn")
})
it("should allow per-registry headers to override the default Accept and User-Agent", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(`${REGISTRY_URL}/override-test.json`, ({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "override-test",
type: "registry:ui",
})
})
)
setRegistryHeaders({
[`${REGISTRY_URL}/override-test.json`]: {
Accept: "application/custom+json",
"User-Agent": "custom-client/1.0",
},
})
await fetchRegistry(["override-test.json"], { useCache: false })
expect(acceptHeader).toBe("application/custom+json")
expect(userAgentHeader).toBe("custom-client/1.0")
clearRegistryContext()
})
it("should allow lowercase per-registry headers to override the default Accept and User-Agent", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(
`${REGISTRY_URL}/lowercase-override-test.json`,
({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "lowercase-override-test",
type: "registry:ui",
})
}
)
)
setRegistryHeaders({
[`${REGISTRY_URL}/lowercase-override-test.json`]: {
accept: "application/custom+json",
"user-agent": "custom-client/1.0",
},
})
await fetchRegistry(["lowercase-override-test.json"], { useCache: false })
expect(acceptHeader).toBe("application/custom+json")
expect(userAgentHeader).toBe("custom-client/1.0")
clearRegistryContext()
})
it("should send specific Accept header for direct external URLs", async () => {
let acceptHeader: string | null = null
server.use(
http.get("https://external.com/registry/item.json", ({ request }) => {
acceptHeader = request.headers.get("accept")
return HttpResponse.json({
name: "item",
type: "registry:ui",
})
})
)
await fetchRegistry(["https://external.com/registry/item.json"], {
useCache: false,
})
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
})
it("should successfully fetch when the server requires the specific Shadcn Accept header (Content Negotiation)", async () => {
server.use(
http.get(`${REGISTRY_URL}/content-negotiation.json`, ({ request }) => {
const accept = request.headers.get("accept")
if (!accept?.includes("application/vnd.shadcn.v1+json")) {
return new HttpResponse(
"<!DOCTYPE html><html><body>Error: Specific header missing</body></html>",
{
status: 200,
headers: { "Content-Type": "text/html" },
}
)
}
return HttpResponse.json({
name: "content-negotiation",
type: "registry:ui",
})
})
)
const [result] = await fetchRegistry(["content-negotiation.json"], {
useCache: false,
})
expect(result).toMatchObject({ name: "content-negotiation" })
})
})
describe("clearRegistryCache", () => {

View File

@@ -14,7 +14,7 @@ import {
} from "@/src/registry/errors"
import { registryItemSchema } from "@/src/schema"
import { HttpsProxyAgent } from "https-proxy-agent"
import fetch from "node-fetch"
import fetch, { Headers } from "node-fetch"
import { z } from "zod"
const agent = process.env.https_proxy
@@ -50,12 +50,18 @@ export async function fetchRegistry(
const fetchPromise = (async () => {
// Get headers from context for this URL.
const headers = getRegistryHeadersFromContext(url)
const requestHeaders = new Headers({
Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
"User-Agent": "shadcn",
})
for (const [key, value] of Object.entries(headers)) {
requestHeaders.set(key, value)
}
const response = await fetch(url, {
agent,
headers: {
...headers,
},
headers: requestHeaders,
})
if (!response.ok) {

2
pnpm-lock.yaml generated
View File

@@ -284,7 +284,7 @@ importers:
specifier: ^0.0.1
version: 0.0.1
shadcn:
specifier: 4.3.0
specifier: 4.4.0
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1

View File

@@ -173,8 +173,9 @@ npx shadcn@latest docs button dialog select
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **overwrite**, **merge**, or **skip**?
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply --preset <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
@@ -209,6 +210,9 @@ npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (ba
# Apply a preset to an existing project.
npx shadcn@latest apply --preset a2r6bw
npx shadcn@latest apply a2r6bw
npx shadcn@latest apply --preset a2r6bw --only theme
npx shadcn@latest apply --preset a2r6bw --only font
npx shadcn@latest apply --preset a2r6bw --only theme,font
# Add components.
npx shadcn@latest add button card dialog