From 652522703637e7abb2be482ea4d6d7f0ebc8046a Mon Sep 17 00:00:00 2001
From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com>
Date: Thu, 26 Mar 2026 00:24:48 -0700
Subject: [PATCH 01/13] fix(cli): add Accept and User-Agent headers to support
content negotiation (fixes #10164)
---
.../content/docs/registry/getting-started.mdx | 41 ++++++++++++
packages/shadcn/src/registry/fetcher.test.ts | 67 +++++++++++++++++++
packages/shadcn/src/registry/fetcher.ts | 2 +
3 files changed, 110 insertions(+)
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index 1616b69980..675570d7de 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -144,6 +144,47 @@ 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 based on the client.
+
+### Identity Headers
+
+When the CLI makes a request to a registry, it sends the following headers:
+
+- **User-Agent**: `shadcn-ui`
+- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
+
+### Root Hosting
+
+By checking these headers on your server, you can serve human-readable documentation (HTML) to browser users and the registry index (JSON) to the CLI—all from the exact same URL.
+
+Here's an example of how to implement this in an Express.js server:
+
+```javascript title="server.js" showLineNumbers
+app.get("/", (req, res) => {
+ const accept = req.get("Accept")
+ const userAgent = req.get("User-Agent")
+
+ // 1. Check if the request is from the shadcn CLI
+ if (
+ accept?.includes("application/vnd.shadcn.v1+json") ||
+ userAgent === "shadcn-ui"
+ ) {
+ // Return the registry JSON for the CLI
+ return res.json(registryData)
+ }
+
+ // 2. Otherwise, serve your documentation or homepage
+ res.send(htmlContent)
+})
+```
+
+This enables:
+- **Branded Registry URLs**: `shadcn add https://ui.example.com`
+- **Shorter URLs**: No need for dedicated `/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/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts
index 549addb04e..608672ed97 100644
--- a/packages/shadcn/src/registry/fetcher.test.ts
+++ b/packages/shadcn/src/registry/fetcher.test.ts
@@ -204,6 +204,73 @@ 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-ui")
+ })
+
+ 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..33d34e3ac9 100644
--- a/packages/shadcn/src/registry/fetcher.ts
+++ b/packages/shadcn/src/registry/fetcher.ts
@@ -54,6 +54,8 @@ export async function fetchRegistry(
const response = await fetch(url, {
agent,
headers: {
+ Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
+ "User-Agent": "shadcn-ui",
...headers,
},
})
From f9b216af77f6e3b0eec57489c1a472a356e3befb Mon Sep 17 00:00:00 2001
From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com>
Date: Thu, 26 Mar 2026 00:24:48 -0700
Subject: [PATCH 02/13] docs(registry): document content negotiation with
Express example
---
.../content/docs/registry/getting-started.mdx | 25 +++++++++----------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index 675570d7de..b418c4f6be 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -144,18 +144,18 @@ 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
+## 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 based on the client.
-### Identity Headers
+### Identity headers
When the CLI makes a request to a registry, it sends the following headers:
- **User-Agent**: `shadcn-ui`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
-### Root Hosting
+### Root hosting
By checking these headers on your server, you can serve human-readable documentation (HTML) to browser users and the registry index (JSON) to the CLI—all from the exact same URL.
@@ -163,24 +163,23 @@ Here's an example of how to implement this in an Express.js server:
```javascript title="server.js" showLineNumbers
app.get("/", (req, res) => {
- const accept = req.get("Accept")
- const userAgent = req.get("User-Agent")
-
- // 1. Check if the request is from the shadcn CLI
- if (
- accept?.includes("application/vnd.shadcn.v1+json") ||
- userAgent === "shadcn-ui"
- ) {
- // Return the registry JSON for the CLI
+ // Check if the client prefers the Shadcn vendor type
+ if (req.accepts("application/vnd.shadcn.v1+json")) {
return res.json(registryData)
}
- // 2. Otherwise, serve your documentation or homepage
+ // Optional: Secondary check for the User-Agent
+ if (req.get("User-Agent") === "shadcn-ui") {
+ 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**: No need for dedicated `/r/` or `/registry/` sub-paths.
- **Easy Mnemonics**: Easier for users to remember and share your registry.
From eeb5d22fe5f8029e065573546482ff9f42a521e1 Mon Sep 17 00:00:00 2001
From: ysds
Date: Wed, 15 Apr 2026 12:08:19 +0900
Subject: [PATCH 03/13] chore(registry): add @exabase registry
---
apps/v4/public/r/registries.json | 6 ++++++
apps/v4/registry/directory.json | 7 +++++++
2 files changed, 13 insertions(+)
diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json
index 3f2714d76f..ca1ba276d7 100644
--- a/apps/v4/public/r/registries.json
+++ b/apps/v4/public/r/registries.json
@@ -1030,5 +1030,11 @@
"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."
}
]
diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json
index 07bda91a58..5e13ee3e8c 100644
--- a/apps/v4/registry/directory.json
+++ b/apps/v4/registry/directory.json
@@ -1203,5 +1203,12 @@
"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": ""
}
]
From de385d04fc3f45b84c9ad75164dde9151fd56083 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Sun, 19 Apr 2026 12:55:07 +0400
Subject: [PATCH 04/13] fix: ensure git init runs for new projects only
---
packages/shadcn/src/commands/init.test.ts | 254 ++++++++++++++++++++++
packages/shadcn/src/commands/init.ts | 12 +-
2 files changed, 262 insertions(+), 4 deletions(-)
create mode 100644 packages/shadcn/src/commands/init.test.ts
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 })
}
From b7cfc364aca36bc90f8efa86773bc81011502036 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Sun, 19 Apr 2026 13:11:24 +0400
Subject: [PATCH 05/13] chore: changeset
---
.changeset/fresh-timers-init.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/fresh-timers-init.md
diff --git a/.changeset/fresh-timers-init.md b/.changeset/fresh-timers-init.md
new file mode 100644
index 0000000000..2308e653d9
--- /dev/null
+++ b/.changeset/fresh-timers-init.md
@@ -0,0 +1,5 @@
+---
+"shadcn": patch
+---
+
+Ensure `init` only runs template post-init hooks for newly created projects.
From cf92d4f8f28013120f3f96c9c9f53b852ef9aeb3 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Sun, 19 Apr 2026 14:59:14 +0400
Subject: [PATCH 06/13] Consolidate release workflows and beta comment handling
---
.github/workflows/prerelease-comment.yml | 9 +--
.github/workflows/prerelease.yml | 64 --------------------
.github/workflows/release.yml | 75 +++++++++++++++++++++---
3 files changed, 71 insertions(+), 77 deletions(-)
delete mode 100644 .github/workflows/prerelease.yml
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
From e8b1be1f22194910e0f34e85b4a2ed135a374dbd Mon Sep 17 00:00:00 2001
From: uiNerd16
Date: Sun, 19 Apr 2026 22:10:56 +0200
Subject: [PATCH 07/13] feat: add @aicanvas registry
---
apps/v4/public/r/registries.json | 6 ++++++
apps/v4/registry/directory.json | 7 +++++++
2 files changed, 13 insertions(+)
diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json
index ca1ba276d7..b7296c43df 100644
--- a/apps/v4/public/r/registries.json
+++ b/apps/v4/public/r/registries.json
@@ -1036,5 +1036,11 @@
"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/directory.json b/apps/v4/registry/directory.json
index 5e13ee3e8c..b3f199992e 100644
--- a/apps/v4/registry/directory.json
+++ b/apps/v4/registry/directory.json
@@ -1210,5 +1210,12 @@
"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": ""
}
]
From f632f5d7982bd08b4a47d50426732bc93d28033f Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 11:55:06 +0400
Subject: [PATCH 08/13] feat: rename header
---
packages/shadcn/src/registry/fetcher.test.ts | 64 +++++++++++++++++++-
packages/shadcn/src/registry/fetcher.ts | 16 +++--
2 files changed, 73 insertions(+), 7 deletions(-)
diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts
index 608672ed97..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(
@@ -223,7 +224,68 @@ describe("fetchRegistry", () => {
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
- expect(userAgentHeader).toBe("shadcn-ui")
+ 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 () => {
diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts
index 33d34e3ac9..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,14 +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: {
- Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
- "User-Agent": "shadcn-ui",
- ...headers,
- },
+ headers: requestHeaders,
})
if (!response.ok) {
From 4bdeea4c63e49313a5bb4110f6c035e6e93821a0 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 11:55:13 +0400
Subject: [PATCH 09/13] docs: update docs
---
.../content/docs/registry/getting-started.mdx | 80 ++++++++++++++++---
1 file changed, 70 insertions(+), 10 deletions(-)
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index b418c4f6be..2b96384a81 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -146,34 +146,94 @@ Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http
## 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 based on the client.
+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.
-### Identity headers
+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-ui`
+- **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 serve human-readable documentation (HTML) to browser users and the registry index (JSON) to the CLI—all from the exact same URL.
+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.
-Here's an example of how to implement this in an Express.js server:
+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) => {
- // Check if the client prefers the Shadcn vendor type
+ 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-ui") {
+ // Optional: Secondary check for the User-Agent.
+ if (req.get("User-Agent") === "shadcn") {
return res.json(registryData)
}
- // Otherwise, serve your documentation or homepage
+ // Otherwise, serve your documentation or homepage.
res.send(htmlContent)
})
```
@@ -181,7 +241,7 @@ app.get("/", (req, res) => {
This enables:
- **Branded Registry URLs**: `shadcn add https://ui.example.com`
-- **Shorter URLs**: No need for dedicated `/r/` or `/registry/` sub-paths.
+- **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
From d00605c5fb5fe3cfbcb68cea65398430cdd819f8 Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 11:55:18 +0400
Subject: [PATCH 10/13] chore: changeset
---
.changeset/quiet-amber-field.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/quiet-amber-field.md
diff --git a/.changeset/quiet-amber-field.md b/.changeset/quiet-amber-field.md
new file mode 100644
index 0000000000..f1cd53cabe
--- /dev/null
+++ b/.changeset/quiet-amber-field.md
@@ -0,0 +1,5 @@
+---
+"shadcn": patch
+---
+
+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.
From 3411d538569dd7ed37dacdbd37fa681adaf2d5ab Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 20 Apr 2026 08:16:18 +0000
Subject: [PATCH 11/13] chore(release): version packages
---
.changeset/fresh-timers-init.md | 5 -----
.changeset/quiet-amber-field.md | 5 -----
apps/v4/package.json | 2 +-
packages/shadcn/CHANGELOG.md | 8 ++++++++
packages/shadcn/package.json | 2 +-
pnpm-lock.yaml | 2 +-
6 files changed, 11 insertions(+), 13 deletions(-)
delete mode 100644 .changeset/fresh-timers-init.md
delete mode 100644 .changeset/quiet-amber-field.md
diff --git a/.changeset/fresh-timers-init.md b/.changeset/fresh-timers-init.md
deleted file mode 100644
index 2308e653d9..0000000000
--- a/.changeset/fresh-timers-init.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"shadcn": patch
----
-
-Ensure `init` only runs template post-init hooks for newly created projects.
diff --git a/.changeset/quiet-amber-field.md b/.changeset/quiet-amber-field.md
deleted file mode 100644
index f1cd53cabe..0000000000
--- a/.changeset/quiet-amber-field.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"shadcn": patch
----
-
-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.
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/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/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
From 9c572ab778b5a0ab42693eb07bc4a75d0c24603e Mon Sep 17 00:00:00 2001
From: shadcn
Date: Mon, 20 Apr 2026 12:29:55 +0400
Subject: [PATCH 12/13] fix: chartColor in presets
---
.changeset/cold-kids-fly.md | 5 +++++
apps/v4/app/(create)/init/parse-config.test.ts | 15 +++++++++++++++
apps/v4/registry/config.test.ts | 17 +++++++++++++++++
apps/v4/registry/config.ts | 6 +++++-
packages/shadcn/src/preset/presets.test.ts | 8 +++++++-
packages/shadcn/src/preset/presets.ts | 7 +++++++
6 files changed, 56 insertions(+), 2 deletions(-)
create mode 100644 .changeset/cold-kids-fly.md
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/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/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/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",
From 11cbc3284033182b7e1e632b82e48bdc575d2a0a Mon Sep 17 00:00:00 2001
From: shadcn
Date: Tue, 21 Apr 2026 11:25:56 +0400
Subject: [PATCH 13/13] refactor: caching for build registry
---
apps/v4/scripts/build-registry.mts | 145 +++++++++++++++++++++++++++--
1 file changed, 136 insertions(+), 9 deletions(-)
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