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