diff --git a/.changeset/cold-kids-fly.md b/.changeset/cold-kids-fly.md
new file mode 100644
index 0000000000..ad774f8155
--- /dev/null
+++ b/.changeset/cold-kids-fly.md
@@ -0,0 +1,5 @@
+---
+"shadcn": patch
+---
+
+fix chartColor in presets
diff --git a/.github/workflows/prerelease-comment.yml b/.github/workflows/prerelease-comment.yml
index 41e7f693a2..238612231b 100644
--- a/.github/workflows/prerelease-comment.yml
+++ b/.github/workflows/prerelease-comment.yml
@@ -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: |
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
deleted file mode 100644
index d77c975933..0000000000
--- a/.github/workflows/prerelease.yml
+++ /dev/null
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ca6aabeb1..ab09eeb067 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/apps/v4/app/(create)/init/parse-config.test.ts b/apps/v4/app/(create)/init/parse-config.test.ts
index 245a7b6151..01a7051c6e 100644
--- a/apps/v4/app/(create)/init/parse-config.test.ts
+++ b/apps/v4/app/(create)/init/parse-config.test.ts
@@ -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(
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index 1616b69980..2b96384a81 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -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.
diff --git a/apps/v4/package.json b/apps/v4/package.json
index 2d83c4f5ba..71e8edfb58 100644
--- a/apps/v4/package.json
+++ b/apps/v4/package.json
@@ -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.3.1",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"swr": "^2.3.6",
diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json
index 6cdfd2fdcf..5a4f00fb01 100644
--- a/apps/v4/public/r/registries.json
+++ b/apps/v4/public/r/registries.json
@@ -1036,5 +1036,17 @@
"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."
}
]
diff --git a/apps/v4/registry/config.test.ts b/apps/v4/registry/config.test.ts
index 1513e35d1b..76809189d7 100644
--- a/apps/v4/registry/config.test.ts
+++ b/apps/v4/registry/config.test.ts
@@ -65,6 +65,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",
diff --git a/apps/v4/registry/config.ts b/apps/v4/registry/config.ts
index a2f825b700..dadab12a16 100644
--- a/apps/v4/registry/config.ts
+++ b/apps/v4/registry/config.ts
@@ -98,7 +98,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 +136,10 @@ export const designSystemConfigSchema = z
.default("next")
.optional(),
})
+ .transform((data) => ({
+ ...data,
+ chartColor: data.chartColor ?? data.theme,
+ }))
.refine(
(data) => {
const availableThemes = getThemesForBaseColor(data.baseColor)
diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json
index 9e301c4ae0..fcd62cd26b 100644
--- a/apps/v4/registry/directory.json
+++ b/apps/v4/registry/directory.json
@@ -1210,5 +1210,19 @@
"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": ""
+ },
+ {
+ "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": ""
+ },
+ {
+ "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": ""
}
]
diff --git a/apps/v4/scripts/build-registry.mts b/apps/v4/scripts/build-registry.mts
index bc48161021..d8ed7e2683 100644
--- a/apps/v4/scripts/build-registry.mts
+++ b/apps/v4/scripts/build-registry.mts
@@ -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()
+type TransformCacheManifestEntry = {
+ inputHash: string
+ outputHash: string
+}
+
+const transformCacheManifest = new Map()
let transformCacheDirty = false
let prettierConfigPromise: Promise | 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 {
+ 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
+ const payload = JSON.parse(existingManifest) as Record
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
}) {
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> = {`
}
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
}> = []
@@ -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
diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md
index 808a5640cd..d864770cbd 100644
--- a/packages/shadcn/CHANGELOG.md
+++ b/packages/shadcn/CHANGELOG.md
@@ -1,5 +1,13 @@
# @shadcn/ui
+## 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
diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json
index 77c1d94f0d..e77329313b 100644
--- a/packages/shadcn/package.json
+++ b/packages/shadcn/package.json
@@ -1,6 +1,6 @@
{
"name": "shadcn",
- "version": "4.3.0",
+ "version": "4.3.1",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"
diff --git a/packages/shadcn/src/commands/init.test.ts b/packages/shadcn/src/commands/init.test.ts
new file mode 100644
index 0000000000..dfce4c17c5
--- /dev/null
+++ b/packages/shadcn/src/commands/init.test.ts
@@ -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) => ({
+ ...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[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()
+ })
+})
diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts
index 650840d3dc..512be0380b 100644
--- a/packages/shadcn/src/commands/init.ts
+++ b/packages/shadcn/src/commands/init.ts
@@ -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 })
}
diff --git a/packages/shadcn/src/preset/presets.test.ts b/packages/shadcn/src/preset/presets.test.ts
index bd1f3d0153..c36b9797f3 100644
--- a/packages/shadcn/src/preset/presets.test.ts
+++ b/packages/shadcn/src/preset/presets.test.ts
@@ -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)
diff --git a/packages/shadcn/src/preset/presets.ts b/packages/shadcn/src/preset/presets.ts
index 815913f0d8..a882712567 100644
--- a/packages/shadcn/src/preset/presets.ts
+++ b/packages/shadcn/src/preset/presets.ts
@@ -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",
diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts
index 549addb04e..7d30448d3e 100644
--- a/packages/shadcn/src/registry/fetcher.test.ts
+++ b/packages/shadcn/src/registry/fetcher.test.ts
@@ -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(
+ "Error: Specific header missing",
+ {
+ 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", () => {
diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts
index e5df988310..8bc76265df 100644
--- a/packages/shadcn/src/registry/fetcher.ts
+++ b/packages/shadcn/src/registry/fetcher.ts
@@ -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) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2f9aeb0e28..4c983f0a64 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -284,7 +284,7 @@ importers:
specifier: ^0.0.1
version: 0.0.1
shadcn:
- specifier: 4.3.0
+ specifier: 4.3.1
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1