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, }, })