diff --git a/.changeset/rotten-humans-cover.md b/.changeset/rotten-humans-cover.md
new file mode 100644
index 00000000000..893b2f5b07d
--- /dev/null
+++ b/.changeset/rotten-humans-cover.md
@@ -0,0 +1,5 @@
+---
+"shadcn": minor
+---
+
+add include to registry.json
diff --git a/apps/v4/content/docs/(root)/v0.mdx b/apps/v4/content/docs/(root)/_v0.mdx
similarity index 100%
rename from apps/v4/content/docs/(root)/v0.mdx
rename to apps/v4/content/docs/(root)/_v0.mdx
diff --git a/apps/v4/content/docs/(root)/meta.json b/apps/v4/content/docs/(root)/meta.json
index a1ad4895976..ee767a33b39 100644
--- a/apps/v4/content/docs/(root)/meta.json
+++ b/apps/v4/content/docs/(root)/meta.json
@@ -11,7 +11,6 @@
"[CLI](/docs/cli)",
"monorepo",
"skills",
- "v0",
"javascript",
"blocks",
"figma",
diff --git a/apps/v4/content/docs/changelog/2026-05-registry-include.mdx b/apps/v4/content/docs/changelog/2026-05-registry-include.mdx
new file mode 100644
index 00000000000..31dea7b17f0
--- /dev/null
+++ b/apps/v4/content/docs/changelog/2026-05-registry-include.mdx
@@ -0,0 +1,98 @@
+---
+title: May 2026 - Registry Include
+description: Organize large registries with included registry.json files.
+date: 2026-05-20
+---
+
+We've added support for `include` in `registry.json`.
+
+Registry authors can now organize a large source registry across multiple
+`registry.json` files and compose them with `shadcn build`.
+
+```txt
+registry.json
+components
+└── ui
+ ├── button.tsx
+ ├── input.tsx
+ └── registry.json
+hooks
+├── registry.json
+├── use-media-query.ts
+└── use-toggle.ts
+```
+
+{/* prettier-ignore */}
+```json title="registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "name": "acme",
+ "homepage": "https://acme.com",
+ "include": [
+ "components/ui/registry.json",
+ "hooks/registry.json"
+ ]
+}
+```
+
+Included `registry.json` files are valid registry files for composition and may
+omit `name` and `homepage`. Only the root `registry.json` must define the
+registry metadata.
+
+```json title="components/ui/registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "items": [
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "files": [
+ {
+ "path": "button.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ }
+ ]
+}
+```
+
+## Build output
+
+`shadcn build` resolves included registries and writes a flattened
+`registry.json` without `include`. Item file paths are preserved from the root
+registry, so a file declared in `components/ui/registry.json` is written as
+`components/ui/button.tsx` in the built registry item.
+
+## Registry loaders
+
+The `shadcn/registry` package also exports `loadRegistry` and
+`loadRegistryItem` for dynamic registry routes.
+
+```ts title="app/r/registry.json/route.ts" showLineNumbers
+import { loadRegistry } from "shadcn/registry"
+
+export async function GET() {
+ const registry = await loadRegistry()
+
+ return Response.json(registry)
+}
+```
+
+```ts title="app/r/[name].json/route.ts" showLineNumbers
+import { loadRegistryItem } from "shadcn/registry"
+
+export async function GET(
+ _: Request,
+ { params }: { params: Promise<{ name: string }> }
+) {
+ const { name } = await params
+ const item = await loadRegistryItem(name)
+
+ return Response.json(item)
+}
+```
+
+See the [registry.json documentation](/docs/registry/registry-json#include) and
+[getting started guide](/docs/registry/getting-started#structure-your-registry)
+for more details.
diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx
index 2b96384a819..02fa0455d70 100644
--- a/apps/v4/content/docs/registry/getting-started.mdx
+++ b/apps/v4/content/docs/registry/getting-started.mdx
@@ -9,7 +9,9 @@ If you're starting a new registry project, you can use the [registry template](h
## Requirements
-You are free to design and host your custom registry as you see fit. The only requirement is that your registry items must be valid JSON files that conform to the [registry-item schema specification](/docs/registry/registry-item-json).
+You are free to design and host your custom registry as you see fit. The only requirement is that your registry catalog and registry items must be valid JSON files that conform to the [registry schema specification](/docs/registry/registry-json) and [registry-item schema specification](/docs/registry/registry-item-json).
+
+Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP.
If you'd like to see an example of a registry, we have a [template project](https://github.com/shadcn-ui/registry-template) for you to use as a starting point.
@@ -19,11 +21,40 @@ The `registry.json` is the entry point for the registry. It contains the registr
Your registry must have this file (or JSON payload) present at the root of the registry endpoint. The registry endpoint is the URL where your registry is hosted.
-The `shadcn` CLI will automatically generate this file for you when you run the `build` command.
+Here's an example `registry.json` file:
+
+```json title="registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "name": "acme",
+ "homepage": "https://acme.com",
+ "items": [
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "title": "Button",
+ "description": "A simple button component.",
+ "files": [
+ {
+ "path": "components/ui/button.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ }
+ ]
+}
+```
+
+## Structure your registry
+
+You can structure your source registry in one of two ways:
+
+- Define all items in a single root `registry.json`.
+- Use a root `registry.json` with `include` to compose multiple `registry.json` files.
-## Add a registry.json file
+### Option A: Single registry.json
-Create a `registry.json` file in the root of your project. Your project can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP.
+Create a `registry.json` file in the root of your project. Add all your registry items to the `items` array. This is the simplest way to define a registry.
```json title="registry.json" showLineNumbers
{
@@ -31,44 +62,171 @@ Create a `registry.json` file in the root of your project. Your project can be a
"name": "acme",
"homepage": "https://acme.com",
"items": [
- // ...
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "title": "Button",
+ "description": "A simple button component.",
+ "files": [
+ {
+ "path": "components/ui/button.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ },
+ {
+ "name": "hello-world",
+ "type": "registry:block",
+ "title": "Hello World",
+ "description": "A simple hello world component.",
+ "registryDependencies": ["button"],
+ "files": [
+ {
+ "path": "registry/default/hello-world/hello-world.tsx",
+ "type": "registry:component"
+ }
+ ]
+ }
]
}
```
This `registry.json` file must conform to the [registry schema specification](/docs/registry/registry-json).
-## Add a registry item
+### Option B: Using include
-### Create your component
+For larger registries, you can use `include` to compose your source registry
+from multiple `registry.json` files.
-Add your first component. Here's an example of a simple ` ` component:
+```txt
+registry.json
+components
+└── ui
+ ├── button.tsx
+ ├── input.tsx
+ └── registry.json
+hooks
+├── registry.json
+├── use-media-query.ts
+└── use-toggle.ts
+```
-```tsx title="registry/new-york/hello-world/hello-world.tsx" showLineNumbers
-import { Button } from "@/components/ui/button"
+The root `registry.json` defines the registry metadata and includes the nested
+registry files.
-export function HelloWorld() {
- return Hello World
+{/* prettier-ignore */}
+```json title="registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "name": "acme",
+ "homepage": "https://acme.com",
+ "include": [
+ "components/ui/registry.json",
+ "hooks/registry.json"
+ ]
+}
+```
+
+Included `registry.json` files are valid registry files for composition and may
+omit `name` and `homepage`. Only the root `registry.json` must define the
+registry metadata.
+
+```json title="components/ui/registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "items": [
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "files": [
+ {
+ "path": "button.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ },
+ {
+ "name": "input",
+ "type": "registry:ui",
+ "files": [
+ {
+ "path": "input.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ }
+ ]
+}
+```
+
+```json title="hooks/registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "items": [
+ {
+ "name": "use-toggle",
+ "type": "registry:hook",
+ "files": [
+ {
+ "path": "use-toggle.ts",
+ "type": "registry:hook"
+ }
+ ]
+ },
+ {
+ "name": "use-media-query",
+ "type": "registry:hook",
+ "files": [
+ {
+ "path": "use-media-query.ts",
+ "type": "registry:hook"
+ }
+ ]
+ }
+ ]
+}
+```
+
+When using `include`, file paths are relative to the `registry.json` file that
+declares the item.
+
+## Add an item
+
+### Create a UI component
+
+Add your first item. Here's an example of a simple ` ` component:
+
+```tsx title="components/ui/button.tsx" showLineNumbers
+import * as React from "react"
+
+export function Button(props: React.ComponentProps<"button">) {
+ return (
+
+ )
}
```
- **Note:** This example places the component in the `registry/new-york`
- directory. You can place it anywhere in your project as long as you set the
- correct path in the `registry.json` file and you follow the `registry/[NAME]`
- directory structure.
+ **Note:** This example places the component in the `components/ui` directory.
+ You can place it anywhere in your project as long as you set the correct path
+ in the `registry.json` file.
```txt
-registry
-└── new-york
- └── hello-world
- └── hello-world.tsx
+components
+└── ui
+ └── button.tsx
```
-### Add your component to the registry
+### Add the item to the registry
-To add your component to the registry, you need to add your component definition to `registry.json`.
+To add your component to the registry, add an item definition to `registry.json`.
+If you are using `include`, add the item to the included `registry.json` file
+that owns the component. For example, add a UI component to
+`components/ui/registry.json`.
```json title="registry.json" showLineNumbers {6-17}
{
@@ -77,14 +235,14 @@ To add your component to the registry, you need to add your component definition
"homepage": "https://acme.com",
"items": [
{
- "name": "hello-world",
- "type": "registry:block",
- "title": "Hello World",
- "description": "A simple hello world component.",
+ "name": "button",
+ "type": "registry:ui",
+ "title": "Button",
+ "description": "A simple button component.",
"files": [
{
- "path": "registry/new-york/hello-world/hello-world.tsx",
- "type": "registry:component"
+ "path": "components/ui/button.tsx",
+ "type": "registry:ui"
}
]
}
@@ -94,76 +252,144 @@ To add your component to the registry, you need to add your component definition
You define your registry item by adding a `name`, `type`, `title`, `description` and `files`.
-For every file you add, you must specify the `path` and `type` of the file. The `path` is the relative path to the file from the root of your project. The `type` is the type of the file.
+For every file you add, you must specify the `path` and `type` of the file. In a single-file registry, the `path` is relative to the root of your project. When using `include`, the `path` is relative to the `registry.json` file that declares the item. The `type` is the type of the file.
You can read more about the registry item schema and file types in the [registry item schema docs](/docs/registry/registry-item-json).
-## Build your registry
+## Serve your registry
+
+You can serve your registry as static JSON files or from dynamic route handlers.
+
+### Option A: Static JSON files
-### Install the shadcn CLI
+Run the build command to generate static registry JSON files.
```bash
-npm install shadcn@latest
+npx shadcn@latest build
```
-### Add a build script
+If your source registry uses `include`, `shadcn build` resolves the included
+registries and writes a flattened registry to your output directory. The
+generated `registry.json` does not contain `include`.
-Add a `registry:build` script to your `package.json` file.
+
+ **Note:** By default, the build command will generate the registry JSON files
+ in `public/r` e.g `public/r/button.json`. You can change the output directory by passing the `--output` option. See the [shadcn build command](/docs/cli#build) for more information.
-```json title="package.json" showLineNumbers
-{
- "scripts": {
- "registry:build": "shadcn build"
- }
-}
+
+
+If you're running your registry on Next.js, you can serve these files by running
+the `next` server. The command might differ for other frameworks.
+
+```bash
+npm run dev
```
-### Run the build script
+Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/button.json`.
+
+### Option B: Dynamic route handlers
-Run the build script to generate the registry JSON files.
+If you want to serve registry JSON from your source `registry.json` at request
+time, use the producer-side loader APIs from `shadcn/registry`.
+
+Install `shadcn` as a runtime dependency:
```bash
-npm run registry:build
+npm install shadcn
```
-
- **Note:** By default, the build script will generate the registry JSON files
- in `public/r` e.g `public/r/hello-world.json`.
+Use `loadRegistry` to serve the registry catalog.
-You can change the output directory by passing the `--output` option. See the [shadcn build command](/docs/cli#build) for more information.
+```ts title="app/r/registry.json/route.ts" showLineNumbers
+import { loadRegistry } from "shadcn/registry"
-
+export async function GET() {
+ try {
+ const registry = await loadRegistry()
-## Serve your registry
+ return Response.json(registry)
+ } catch (error) {
+ console.error(error)
-If you're running your registry on Next.js, you can now serve your registry by running the `next` server. The command might differ for other frameworks.
+ return Response.json({ error: "Failed to load registry." }, { status: 500 })
+ }
+}
+```
-```bash
-npm run dev
+Use `loadRegistryItem` to serve individual registry items.
+
+```ts title="app/r/[name].json/route.ts" showLineNumbers
+import { loadRegistryItem, RegistryItemNotFoundError } from "shadcn/registry"
+
+export async function GET(
+ _request: Request,
+ context: {
+ params: Promise<{
+ name: string
+ }>
+ }
+) {
+ const { name } = await context.params
+
+ try {
+ const item = await loadRegistryItem(name)
+
+ return Response.json(item)
+ } catch (error) {
+ if (error instanceof RegistryItemNotFoundError) {
+ return Response.json(
+ { error: `Registry item "${name}" was not found.` },
+ { status: 404 }
+ )
+ }
+
+ console.error(error)
+
+ return Response.json(
+ { error: "Failed to load registry item." },
+ { status: 500 }
+ )
+ }
+}
```
-Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
+Both loaders resolve `include` before returning JSON, so route handlers can use
+the same source `registry.json` structure without running `shadcn build`.
-## 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 depending on who is asking.
From a single URL, you can serve:
-- **HTML** to browsers — a landing page, documentation, or marketing site.
-- **JSON** to the `shadcn` CLI — an installable registry item.
-- **Markdown** to AI agents and LLMs — a machine-readable version of your content.
+
+
+ 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
+#### Request headers
When the CLI makes a request to a registry, it sends the following headers:
- **User-Agent**: `shadcn`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
-### Root hosting
+#### Root hosting
By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage.
@@ -240,34 +466,179 @@ app.get("/", (req, res) => {
This enables:
-- **Branded Registry URLs**: `shadcn add https://ui.example.com`
-- **Shorter URLs**: Users type your domain root, not `/r/` or `/registry/` sub-paths.
-- **Easy Mnemonics**: Easier for users to remember and share your registry.
+
+
+ Branded Registry URLs :{" "}
+ shadcn add https://ui.example.com
+
+
+ Shorter URLs : Users type your domain root, not{" "}
+ /r/ or /registry/ sub-paths.
+
+
+ Easy Mnemonics : Easier for users to remember and share your
+ registry.
+
+
+
+
+
+
+
+
+
+## Test your registry
+
+After your registry is being served, test it with the same CLI commands that
+other developers will use.
+
+### Using URL
+
+Use the catalog URL for commands that discover items, like `list` and `search`.
+Use item URLs for commands that read or install a specific item, like `view` and
+`add`.
+
+#### List items
+
+Start by confirming that the registry catalog can be discovered.
+
+```bash
+npx shadcn@latest list http://localhost:3000/r/registry.json
+```
+
+#### Search items
+
+Search the registry by query.
+
+```bash
+npx shadcn@latest search http://localhost:3000/r/registry.json --query button
+```
+
+#### View an item
+
+Then view one registry item by name.
+
+```bash
+npx shadcn@latest view http://localhost:3000/r/button.json
+```
+
+#### Add an item
+
+To test the install flow, run `add` from a project where you want to install the
+item.
+
+```bash
+npx shadcn@latest add http://localhost:3000/r/button.json
+```
+
+### Using namespace
+
+#### Add the registry
+
+You can also test your registry with a namespace. From a project with a
+`components.json` file, add your registry URL template to the project.
+
+```bash
+npx shadcn@latest registry add @acme=http://localhost:3000/r/{name}.json
+```
+
+The `{name}` placeholder must resolve to an item JSON file. For example,
+`@acme/button` resolves to `http://localhost:3000/r/button.json`. The catalog is
+still served separately at `http://localhost:3000/r/registry.json`.
+
+#### List items
+
+Then list the items in your registry.
+
+```bash
+npx shadcn@latest list @acme
+```
+
+#### Search items
+
+Search the registry by query.
+
+```bash
+npx shadcn@latest search @acme --query button
+```
+
+#### View an item
+
+View one registry item by name.
+
+```bash
+npx shadcn@latest view @acme/button
+```
+
+#### Add an item
+
+To test the install flow, run `add` from a project where you want to install the
+item.
+
+```bash
+npx shadcn@latest add @acme/button
+```
+
+See the [Namespaced Registries](/docs/registry/namespace) docs for more
+information.
## Publish your registry
-To make your registry available to other developers, you can publish it by deploying your project to a public URL.
+To make your registry available to other developers, publish your project to a
+public URL. Once deployed, users can install items directly from item URLs, or
+they can add your registry as a namespace in their project.
-## Guidelines
+### Share namespace setup instructions
-Here are some guidelines to follow when building components for a registry.
+If you want users to install items with a namespace like `@acme/button`, tell
+them to add your registry URL template to their project. The `{name}`
+placeholder is replaced by the item name when the CLI resolves the registry
+item.
-- Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `new-york` as an example. It can be anything you want as long as it's nested under the `registry` directory.
-- The following properties are required for the block definition: `name`, `description`, `type` and `files`.
-- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
-- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is the name of the component in the registry eg. `input`, `button`, `card`, etc or a URL to a registry item eg. `http://localhost:3000/r/editor.json`.
-- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
-- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/new-york/hello-world/hello-world"`
-- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.
+The template must resolve to item JSON files. For example, `@acme/button`
+resolves to `https://acme.com/r/button.json`. Your registry catalog should still
+be served separately at `https://acme.com/r/registry.json`.
-## Install using the CLI
+They can add the namespace with the CLI.
-To install a registry item using the `shadcn` CLI, use the `add` command followed by the URL of the registry item.
+```bash
+npx shadcn@latest registry add @acme=https://acme.com/r/{name}.json
+```
+
+Or they can add it manually under the `registries` field in their
+`components.json` file.
+
+```json title="components.json" showLineNumbers
+{
+ "registries": {
+ "@acme": "https://acme.com/r/{name}.json"
+ }
+}
+```
+
+Users can then consume items from your registry by namespace.
```bash
-npx shadcn@latest add http://localhost:3000/r/hello-world.json
+npx shadcn@latest add @acme/button
```
-See the [Namespaced
-Registries](/docs/registry/namespace) docs for more information on
-how to install registry items from a namespaced registry.
+### Add your namespace to the registry index
+
+If your registry is open source and publicly available, you can submit your
+namespace to the official registry index. This lets users add your namespace by
+name instead of pasting the full URL template.
+
+See the [Registry Index](/docs/registry/registry-index) docs for the submission
+requirements.
+
+## Guidelines
+
+Here are some guidelines to follow when building components for a registry.
+
+- Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `default` as an example. It can be anything you want as long as it's nested under the `registry` directory.
+- For blocks, the following properties are required: `name`, `description`, `type` and `files`.
+- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
+- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is the name of the component in the registry eg. `input`, `button`, `card`, etc or a URL to a registry item eg. `http://localhost:3000/r/editor.json`.
+- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
+- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/default/hello-world/hello-world"`
+- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.
diff --git a/apps/v4/content/docs/registry/meta.json b/apps/v4/content/docs/registry/meta.json
index ae2e62b7226..4905cedbc92 100644
--- a/apps/v4/content/docs/registry/meta.json
+++ b/apps/v4/content/docs/registry/meta.json
@@ -3,11 +3,11 @@
"pages": [
"index",
"getting-started",
+ "registry-index",
+ "examples",
"namespace",
"authentication",
- "examples",
"mcp",
- "registry-index",
"open-in-v0",
"registry-json",
"registry-item-json"
diff --git a/apps/v4/content/docs/registry/registry-index.mdx b/apps/v4/content/docs/registry/registry-index.mdx
index ad55d2a19a9..1fffdf07975 100644
--- a/apps/v4/content/docs/registry/registry-index.mdx
+++ b/apps/v4/content/docs/registry/registry-index.mdx
@@ -1,5 +1,5 @@
---
-title: Add a Registry
+title: Registry Directory
description: Open Source Registry Index
---
@@ -17,7 +17,7 @@ You can see the full list at [https://ui.shadcn.com/r/registries.json](https://u
Once you have submitted your request, it will be validated and reviewed by the team.
-### Requirements
+## Requirements
1. The registry must be open source and publicly accessible.
2. The registry must be a valid JSON file that conforms to the [registry schema specification](/docs/registry/registry-json).
diff --git a/apps/v4/content/docs/registry/registry-json.mdx b/apps/v4/content/docs/registry/registry-json.mdx
index 7a71bf5b9b2..710df0d9833 100644
--- a/apps/v4/content/docs/registry/registry-json.mdx
+++ b/apps/v4/content/docs/registry/registry-json.mdx
@@ -24,7 +24,7 @@ The `registry.json` schema is used to define your custom component registry.
"dependencies": ["is-even@3.0.0", "motion"],
"files": [
{
- "path": "registry/new-york/hello-world/hello-world.tsx",
+ "path": "registry/default/hello-world/hello-world.tsx",
"type": "registry:component"
}
]
@@ -33,6 +33,22 @@ The `registry.json` schema is used to define your custom component registry.
}
```
+You can also organize a large registry across multiple `registry.json` files
+using `include`.
+
+{/* prettier-ignore */}
+```json title="registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "name": "acme",
+ "homepage": "https://acme.com",
+ "include": [
+ "components/ui/registry.json",
+ "hooks/registry.json"
+ ]
+}
+```
+
## Definitions
You can see the JSON Schema for `registry.json` [here](https://ui.shadcn.com/schema/registry.json).
@@ -67,6 +83,61 @@ The homepage of your registry. This is used for data attributes and other metada
}
```
+### include
+
+The `include` property is used to compose a registry from other `registry.json`
+files.
+
+{/* prettier-ignore */}
+```json title="registry.json" showLineNumbers
+{
+ "include": [
+ "components/ui/registry.json",
+ "hooks/registry.json"
+ ]
+}
+```
+
+Each include path must be a relative path to an explicit `registry.json` file.
+Folder shorthand is not supported.
+
+{/* prettier-ignore */}
+```json title="registry.json" showLineNumbers
+{
+ "include": [
+ "components/ui/registry.json"
+ ]
+}
+```
+
+Included `registry.json` files may omit `name` and `homepage`. These fields are
+required only on the root `registry.json`.
+
+```json title="components/ui/registry.json" showLineNumbers
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "items": [
+ {
+ "name": "button",
+ "type": "registry:ui",
+ "files": [
+ {
+ "path": "button.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ }
+ ]
+}
+```
+
+When `shadcn build` resolves includes, item file paths are read relative to the
+`registry.json` file that declares the item. The generated registry output is
+flattened and does not contain `include`.
+
+Registry item names must be unique across the resolved registry, including all
+included files.
+
### items
The `items` in your registry. Each item must implement the [registry-item schema specification](https://ui.shadcn.com/schema/registry-item.json).
@@ -87,7 +158,7 @@ The `items` in your registry. Each item must implement the [registry-item schema
"dependencies": ["is-even@3.0.0", "motion"],
"files": [
{
- "path": "registry/new-york/hello-world/hello-world.tsx",
+ "path": "registry/default/hello-world/hello-world.tsx",
"type": "registry:component"
}
]
@@ -96,4 +167,7 @@ The `items` in your registry. Each item must implement the [registry-item schema
}
```
+The root `registry.json` must define at least one of `items` or `include`. If
+`items` is omitted, it defaults to an empty array.
+
See the [registry-item schema documentation](/docs/registry/registry-item-json) for more information.
diff --git a/apps/v4/lib/docs.ts b/apps/v4/lib/docs.ts
index 4225cc911f3..5c017f50c46 100644
--- a/apps/v4/lib/docs.ts
+++ b/apps/v4/lib/docs.ts
@@ -1,8 +1,8 @@
export const PAGES_NEW = [
"/create",
- "/docs/cli",
+ "/docs/registry",
+ "/docs/registry/getting-started",
"/docs/changelog",
- "/docs/skills",
]
export const PAGES_UPDATED = ["/docs/components/button"]
diff --git a/apps/v4/public/schema/registry.json b/apps/v4/public/schema/registry.json
index 6f21ab4b24e..3201ec6c2f0 100644
--- a/apps/v4/public/schema/registry.json
+++ b/apps/v4/public/schema/registry.json
@@ -3,20 +3,31 @@
"description": "A shadcn registry of components, hooks, pages, etc.",
"type": "object",
"properties": {
+ "$schema": {
+ "type": "string"
+ },
"name": {
+ "description": "The registry name. Required when this file is used as the root registry, optional for included registry chunks.",
"type": "string"
},
"homepage": {
+ "description": "The registry homepage. Required when this file is used as the root registry, optional for included registry chunks.",
"type": "string"
},
+ "include": {
+ "type": "array",
+ "description": "An array of relative paths to registry.json files to include in this registry.",
+ "items": {
+ "type": "string"
+ }
+ },
"items": {
"type": "array",
+ "default": [],
"items": {
"$ref": "https://ui.shadcn.com/schema/registry-item.json"
}
}
},
- "required": ["name", "homepage", "items"],
- "uniqueItems": true,
- "minItems": 1
+ "anyOf": [{ "required": ["items"] }, { "required": ["include"] }]
}
diff --git a/packages/shadcn/src/commands/build.test.ts b/packages/shadcn/src/commands/build.test.ts
new file mode 100644
index 00000000000..5b63b61da2e
--- /dev/null
+++ b/packages/shadcn/src/commands/build.test.ts
@@ -0,0 +1,100 @@
+import * as fs from "fs/promises"
+import { tmpdir } from "os"
+import * as path from "path"
+import { describe, expect, it, vi } from "vitest"
+
+import { build } from "./build"
+
+vi.mock("@/src/utils/handle-error", () => ({
+ handleError: vi.fn((error) => {
+ throw error
+ }),
+}))
+
+vi.mock("@/src/utils/spinner", () => ({
+ spinner: () => ({
+ start: vi.fn(),
+ succeed: vi.fn(),
+ }),
+}))
+
+describe("build command", () => {
+ it("writes flattened registries for source registries that use include", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["components/ui/registry.json"],
+ }),
+ "components/ui/registry.json": JSON.stringify({
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ "components/ui/button.tsx": "export function Button() {}",
+ })
+
+ await build.parseAsync(
+ ["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
+ { from: "node" }
+ )
+
+ const outputDir = path.join(cwd, "public/r")
+ const registry = JSON.parse(
+ await fs.readFile(path.join(outputDir, "registry.json"), "utf-8")
+ )
+ const button = JSON.parse(
+ await fs.readFile(path.join(outputDir, "button.json"), "utf-8")
+ )
+
+ expect(registry).toMatchObject({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ files: [
+ {
+ path: "components/ui/button.tsx",
+ },
+ ],
+ },
+ ],
+ })
+ expect(registry).not.toHaveProperty("include")
+ expect(registry.items[0].files[0]).not.toHaveProperty("content")
+ expect(button).toMatchObject({
+ $schema: "https://ui.shadcn.com/schema/registry-item.json",
+ name: "button",
+ files: [
+ {
+ path: "components/ui/button.tsx",
+ content: "export function Button() {}",
+ },
+ ],
+ })
+ })
+})
+
+async function createFixture(files: Record) {
+ const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-build-"))
+
+ await Promise.all(
+ Object.entries(files).map(async ([filePath, content]) => {
+ const targetPath = path.join(cwd, filePath)
+ await fs.mkdir(path.dirname(targetPath), { recursive: true })
+ await fs.writeFile(targetPath, content)
+ })
+ )
+
+ return cwd
+}
diff --git a/packages/shadcn/src/commands/build.ts b/packages/shadcn/src/commands/build.ts
index 5515f946893..f6426c92693 100644
--- a/packages/shadcn/src/commands/build.ts
+++ b/packages/shadcn/src/commands/build.ts
@@ -1,10 +1,12 @@
import * as fs from "fs/promises"
import * as path from "path"
import { preFlightBuild } from "@/src/preflights/preflight-build"
-import { SHADCN_URL } from "@/src/registry/constants"
-import { registryItemSchema, registrySchema } from "@/src/schema"
+import {
+ createRegistryCatalog,
+ createRegistryItem,
+ readRegistryWithIncludes,
+} from "@/src/registry/loader"
import { handleError } from "@/src/utils/handle-error"
-import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
@@ -30,67 +32,64 @@ export const build = new Command()
"the working directory. defaults to the current directory.",
process.cwd()
)
- .action(async (registry: string, opts) => {
+ .action(async (registryFile: string, opts) => {
try {
const options = buildOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
- registryFile: registry,
+ registryFile,
outputDir: opts.output,
})
const { resolvePaths } = await preFlightBuild(options)
- const content = await fs.readFile(resolvePaths.registryFile, "utf-8")
-
- const result = registrySchema.safeParse(JSON.parse(content))
-
- if (!result.success) {
- logger.error(
- `Invalid registry file found at ${highlighter.info(
- resolvePaths.registryFile
- )}.`
- )
- process.exit(1)
- }
+ const registryResult = await readRegistryWithIncludes(
+ resolvePaths.registryFile,
+ {
+ cwd: resolvePaths.cwd,
+ }
+ )
+ const resolvedRegistry = registryResult.registry
+ const registryRootDir = registryResult.usesInclude
+ ? path.dirname(resolvePaths.registryFile)
+ : resolvePaths.cwd
+ const registryCatalog = createRegistryCatalog(
+ registryResult,
+ registryRootDir,
+ resolvePaths.cwd
+ )
const buildSpinner = spinner("Building registry...")
- for (const registryItem of result.data.items) {
+ for (const registryItem of resolvedRegistry.items) {
buildSpinner.start(`Building ${registryItem.name}...`)
- // Add the schema to the registry item.
- registryItem["$schema"] =
- "https://ui.shadcn.com/schema/registry-item.json"
-
- // Loop through each file in the files array.
- for (const file of registryItem.files ?? []) {
- file["content"] = await fs.readFile(
- path.resolve(resolvePaths.cwd, file.path),
- "utf-8"
- )
- }
-
- // Validate the registry item.
- const result = registryItemSchema.safeParse(registryItem)
- if (!result.success) {
- logger.error(
- `Invalid registry item found for ${highlighter.info(
- registryItem.name
- )}.`
- )
- continue
- }
+ const registryItemForBuild = await createRegistryItem(
+ registryItem,
+ registryResult,
+ registryRootDir,
+ resolvePaths.cwd
+ )
// Write the registry item to the output directory.
await fs.writeFile(
- path.resolve(resolvePaths.outputDir, `${result.data.name}.json`),
- JSON.stringify(result.data, null, 2)
+ path.resolve(
+ resolvePaths.outputDir,
+ `${registryItemForBuild.name}.json`
+ ),
+ JSON.stringify(registryItemForBuild, null, 2)
)
}
- // Copy registry.json to the output directory.
- await fs.copyFile(
- resolvePaths.registryFile,
- path.resolve(resolvePaths.outputDir, "registry.json")
- )
+ if (registryResult.usesInclude) {
+ await fs.writeFile(
+ path.resolve(resolvePaths.outputDir, "registry.json"),
+ JSON.stringify(registryCatalog, null, 2)
+ )
+ } else {
+ // Copy registry.json to the output directory.
+ await fs.copyFile(
+ resolvePaths.registryFile,
+ path.resolve(resolvePaths.outputDir, "registry.json")
+ )
+ }
buildSpinner.succeed("Building registry.")
} catch (error) {
diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts
index f2513aa0d51..4d57aa2bedb 100644
--- a/packages/shadcn/src/registry/api.test.ts
+++ b/packages/shadcn/src/registry/api.test.ts
@@ -13,6 +13,7 @@ import {
RegistryNotFoundError,
RegistryParseError,
RegistryUnauthorizedError,
+ RegistryValidationError,
} from "@/src/registry/errors"
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
@@ -861,6 +862,36 @@ describe("getRegistry", () => {
expect(result.items).toHaveLength(0)
})
+ it("should reject source registries that use include", async () => {
+ server.use(
+ http.get("https://source.com/registry.json", () => {
+ return HttpResponse.json({
+ name: "@source/registry",
+ homepage: "https://source.com",
+ include: ["registry/ui/registry.json"],
+ items: [],
+ })
+ })
+ )
+
+ const mockConfig = {
+ style: "new-york",
+ tailwind: { baseColor: "neutral", cssVariables: true },
+ registries: {
+ "@source": {
+ url: "https://source.com/{name}.json",
+ },
+ },
+ } as any
+
+ await expect(
+ getRegistry("@source", { config: mockConfig })
+ ).rejects.toThrow(RegistryValidationError)
+ await expect(
+ getRegistry("@source", { config: mockConfig })
+ ).rejects.toThrow("must serve a resolved registry catalog")
+ })
+
it("should handle 404 error from registry endpoint", async () => {
server.use(
http.get("https://notfound.com/registry.json", () => {
@@ -1096,9 +1127,12 @@ describe("getRegistry", () => {
} catch (error) {
expect(error).toBeInstanceOf(RegistryParseError)
if (error instanceof RegistryParseError) {
- expect(error.message).toContain("Failed to parse registry")
+ expect(error.message).toContain("Failed to parse registry catalog")
expect(error.message).toContain("@parsetest/registry")
expect(error.context?.item).toBe("@parsetest/registry")
+ expect(error.suggestion).toContain(
+ "https://ui.shadcn.com/schema/registry.json"
+ )
expect(error.parseError).toBeDefined()
if (error.parseError instanceof z.ZodError) {
expect(error.parseError.errors.length).toBeGreaterThan(0)
@@ -1174,6 +1208,28 @@ describe("getRegistry", () => {
})
})
+ it("should reject direct URL source registries that use include", async () => {
+ const registryUrl = "https://example.com/source-registry.json"
+
+ server.use(
+ http.get(registryUrl, () => {
+ return HttpResponse.json({
+ name: "source-registry",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json"],
+ items: [],
+ })
+ })
+ )
+
+ await expect(getRegistry(registryUrl)).rejects.toThrow(
+ RegistryValidationError
+ )
+ await expect(getRegistry(registryUrl)).rejects.toThrow(
+ "must serve a resolved registry catalog"
+ )
+ })
+
it("should handle malformed URL gracefully", async () => {
const badUrl = "not-a-valid-url"
diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts
index 060cc8b304c..5b3887c3ffa 100644
--- a/packages/shadcn/src/registry/api.ts
+++ b/packages/shadcn/src/registry/api.ts
@@ -16,6 +16,7 @@ import {
RegistryInvalidNamespaceError,
RegistryNotFoundError,
RegistryParseError,
+ RegistryValidationError,
} from "@/src/registry/errors"
import { fetchRegistry } from "@/src/registry/fetcher"
import {
@@ -51,11 +52,7 @@ export async function getRegistry(
if (isUrl(name)) {
const [result] = await fetchRegistry([name], { useCache })
- try {
- return registrySchema.parse(result)
- } catch (error) {
- throw new RegistryParseError(name, error)
- }
+ return parseRegistryCatalog(name, result)
}
if (!name.startsWith("@")) {
@@ -84,10 +81,38 @@ export async function getRegistry(
const [result] = await fetchRegistry([urlAndHeaders.url], { useCache })
+ return parseRegistryCatalog(registryName, result)
+}
+
+function parseRegistryCatalog(name: string, result: unknown) {
try {
- return registrySchema.parse(result)
+ const registry = registrySchema.parse(result)
+
+ if (registry.include?.length) {
+ throw new RegistryValidationError(
+ `Registry catalog "${name}" uses "include", but consumer registry endpoints must serve a resolved registry catalog. Run "npx shadcn build" and serve the built registry.json, or use loadRegistry() in a dynamic route.`,
+ {
+ context: {
+ registry: name,
+ include: registry.include,
+ },
+ suggestion:
+ "Serve a flattened registry.json for CLI consumers. Source registry.json files with include are supported by shadcn build and loadRegistry().",
+ }
+ )
+ }
+
+ return registry
} catch (error) {
- throw new RegistryParseError(registryName, error)
+ if (error instanceof RegistryValidationError) {
+ throw error
+ }
+
+ throw new RegistryParseError(name, error, {
+ subject: "registry catalog",
+ suggestion:
+ "The registry catalog may be corrupted or have an invalid format. Please make sure it returns a valid registry.json object. See https://ui.shadcn.com/schema/registry.json.",
+ })
}
}
diff --git a/packages/shadcn/src/registry/errors.ts b/packages/shadcn/src/registry/errors.ts
index 18f7a7e1ff3..121949d28d2 100644
--- a/packages/shadcn/src/registry/errors.ts
+++ b/packages/shadcn/src/registry/errors.ts
@@ -214,14 +214,24 @@ export class RegistryNotConfiguredError extends RegistryError {
export class RegistryLocalFileError extends RegistryError {
constructor(
public readonly filePath: string,
- cause?: unknown
+ cause?: unknown,
+ options: {
+ message?: string
+ context?: Record
+ suggestion?: string
+ } = {}
) {
- super(`Failed to read local registry file: ${filePath}`, {
- code: RegistryErrorCode.LOCAL_FILE_ERROR,
- cause,
- context: { filePath },
- suggestion: "Check if the file exists and you have read permissions.",
- })
+ super(
+ options.message ?? `Failed to read local registry file: ${filePath}`,
+ {
+ code: RegistryErrorCode.LOCAL_FILE_ERROR,
+ cause,
+ context: { filePath, ...options.context },
+ suggestion:
+ options.suggestion ??
+ "Check if the file exists and you have read permissions.",
+ }
+ )
this.name = "RegistryLocalFileError"
}
}
@@ -231,12 +241,18 @@ export class RegistryParseError extends RegistryError {
constructor(
public readonly item: string,
- parseError: unknown
+ parseError: unknown,
+ options: {
+ subject?: string
+ context?: Record
+ suggestion?: string
+ } = {}
) {
- let message = `Failed to parse registry item: ${item}`
+ const subject = options.subject ?? "registry item"
+ let message = `Failed to parse ${subject}: ${item}`
if (parseError instanceof z.ZodError) {
- message = `Failed to parse registry item: ${item}\n${parseError.errors
+ message = `Failed to parse ${subject}: ${item}\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
}
@@ -244,8 +260,10 @@ export class RegistryParseError extends RegistryError {
super(message, {
code: RegistryErrorCode.PARSE_ERROR,
cause: parseError,
- context: { item },
- suggestion: `The registry item may be corrupted or have an invalid format. Please make sure it returns a valid JSON object. See ${SHADCN_URL}/schema/registry-item.json.`,
+ context: { item, ...options.context },
+ suggestion:
+ options.suggestion ??
+ `The registry item may be corrupted or have an invalid format. Please make sure it returns a valid JSON object. See ${SHADCN_URL}/schema/registry-item.json.`,
})
this.parseError = parseError
@@ -253,6 +271,44 @@ export class RegistryParseError extends RegistryError {
}
}
+export class RegistryValidationError extends RegistryError {
+ constructor(
+ message: string,
+ options: {
+ registryFile?: string
+ cause?: unknown
+ context?: Record
+ suggestion?: string
+ } = {}
+ ) {
+ super(message, {
+ code: RegistryErrorCode.VALIDATION_ERROR,
+ cause: options.cause,
+ context: {
+ ...(options.registryFile ? { registryFile: options.registryFile } : {}),
+ ...options.context,
+ },
+ suggestion:
+ options.suggestion ??
+ "Update the registry.json file and try running the command again.",
+ })
+ this.name = "RegistryValidationError"
+ }
+}
+
+export class RegistryItemNotFoundError extends RegistryError {
+ constructor(public readonly itemName: string) {
+ super(`Registry item "${itemName}" was not found.`, {
+ code: RegistryErrorCode.NOT_FOUND,
+ statusCode: 404,
+ context: { itemName },
+ suggestion:
+ "Check that the item name exists in the resolved registry catalog.",
+ })
+ this.name = "RegistryItemNotFoundError"
+ }
+}
+
export class RegistryMissingEnvironmentVariablesError extends RegistryError {
constructor(
public readonly registryName: string,
diff --git a/packages/shadcn/src/registry/index.ts b/packages/shadcn/src/registry/index.ts
index b3f0a6c76bd..7bc235c193e 100644
--- a/packages/shadcn/src/registry/index.ts
+++ b/packages/shadcn/src/registry/index.ts
@@ -9,6 +9,13 @@ export {
export { searchRegistries } from "./search"
export {
+ loadRegistry,
+ loadRegistryItem,
+ type LoadRegistryOptions,
+} from "./loader"
+
+export {
+ RegistryErrorCode,
RegistryError,
RegistryNotFoundError,
RegistryUnauthorizedError,
@@ -17,6 +24,8 @@ export {
RegistryNotConfiguredError,
RegistryLocalFileError,
RegistryParseError,
+ RegistryValidationError,
+ RegistryItemNotFoundError,
RegistriesIndexParseError,
RegistryMissingEnvironmentVariablesError,
RegistryInvalidNamespaceError,
diff --git a/packages/shadcn/src/registry/loader.test.ts b/packages/shadcn/src/registry/loader.test.ts
new file mode 100644
index 00000000000..88662178fef
--- /dev/null
+++ b/packages/shadcn/src/registry/loader.test.ts
@@ -0,0 +1,605 @@
+import * as fs from "fs/promises"
+import { tmpdir } from "os"
+import * as path from "path"
+import { describe, expect, it } from "vitest"
+
+import {
+ RegistryErrorCode,
+ RegistryItemNotFoundError,
+ RegistryLocalFileError,
+ RegistryParseError,
+ RegistryValidationError,
+} from "./errors"
+import {
+ getRegistryItemFileRootPath,
+ getRegistryItemFileSource,
+ loadRegistry,
+ loadRegistryItem,
+ readRegistryWithIncludes,
+} from "./loader"
+
+describe("readRegistryWithIncludes", () => {
+ it("resolves explicit registry.json includes before local items", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json", "registry/hooks/registry.json"],
+ items: [
+ {
+ name: "root-item",
+ type: "registry:item",
+ },
+ ],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ "registry/ui/button.tsx": "export function Button() {}",
+ "registry/hooks/registry.json": JSON.stringify({
+ name: "example-hooks",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "use-toggle",
+ type: "registry:hook",
+ files: [
+ {
+ path: "use-toggle.ts",
+ type: "registry:hook",
+ },
+ ],
+ },
+ ],
+ }),
+ "registry/hooks/use-toggle.ts": "export function useToggle() {}",
+ })
+
+ const result = await readRegistryWithIncludes("registry.json", { cwd })
+
+ expect(result.usesInclude).toBe(true)
+ expect(result.registry).toMatchObject({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ { name: "button" },
+ { name: "use-toggle" },
+ { name: "root-item" },
+ ],
+ })
+ expect(result.registry).not.toHaveProperty("include")
+ expect(
+ getRegistryItemFileSource("button", "button.tsx", result.itemSources, cwd)
+ ).toBe(path.join(cwd, "registry/ui/button.tsx"))
+ expect(
+ getRegistryItemFileRootPath(
+ "button",
+ "button.tsx",
+ result.itemSources,
+ cwd,
+ cwd
+ )
+ ).toBe("registry/ui/button.tsx")
+ })
+
+ it("rejects root registries without name and homepage", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ include: ["registry/ui/registry.json"],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ items: [],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow('root registry.json must define "name" and "homepage"')
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("reports invalid registry JSON as a parse error", async () => {
+ const cwd = await createFixture({
+ "registry.json": "{",
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryParseError)
+ })
+
+ it("rejects include targets that are not registry.json files", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui.json"],
+ items: [],
+ }),
+ "registry/ui.json": JSON.stringify({
+ name: "example-ui",
+ homepage: "https://example.com",
+ items: [],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow('Use a path like "./registry/ui/registry.json"')
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects remote include paths", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["https://example.com/registry.json"],
+ items: [],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("remote includes are not supported")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects absolute include paths", async () => {
+ const cwd = await createFixture({
+ "registry/ui/registry.json": JSON.stringify({
+ items: [],
+ }),
+ })
+ await fs.writeFile(
+ path.join(cwd, "registry.json"),
+ JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: [path.join(cwd, "registry/ui/registry.json")],
+ items: [],
+ })
+ )
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("include paths must be relative")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects include cycles", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["./registry.json"],
+ items: [],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("Registry include cycle detected")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects include trees that exceed the maximum depth", async () => {
+ const files: Record = {
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry-1/registry.json"],
+ items: [],
+ }),
+ }
+
+ for (let index = 1; index <= 33; index++) {
+ const registryPath = `${Array.from(
+ { length: index },
+ (_, nestedIndex) => `registry-${nestedIndex + 1}`
+ ).join("/")}/registry.json`
+ files[registryPath] = JSON.stringify({
+ include:
+ index < 33 ? [`registry-${index + 1}/registry.json`] : undefined,
+ items: [],
+ })
+ }
+
+ const cwd = await createFixture(files)
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("Registry include tree is too deep")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects duplicate include files before duplicate item validation", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json", "registry/ui/./registry.json"],
+ items: [],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ },
+ ],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("Registry file included more than once")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects duplicate item names in the resolved catalog", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json"],
+ items: [
+ {
+ name: "button",
+ type: "registry:block",
+ },
+ ],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ name: "example-ui",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ },
+ ],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("Rename one of these items")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("rejects parent traversal in item file paths for include composition", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json"],
+ items: [],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ name: "example-ui",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "../button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ })
+
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toThrow("file paths cannot use parent-directory traversal")
+ await expect(
+ readRegistryWithIncludes("registry.json", { cwd })
+ ).rejects.toBeInstanceOf(RegistryValidationError)
+ })
+
+ it("keeps legacy single-file registries compatible", async () => {
+ const cwd = await createFixture({
+ "registry.flat.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "../button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ })
+
+ const result = await readRegistryWithIncludes("registry.flat.json", {
+ cwd,
+ })
+
+ expect(result.usesInclude).toBe(false)
+ expect(result.registry.items).toHaveLength(1)
+ })
+
+ it("keeps legacy file paths cwd-relative for nested single-file registries", async () => {
+ const cwd = await createFixture({
+ "registry/registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "components/button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ "components/button.tsx": "export function Button() {}",
+ })
+
+ const registry = await loadRegistry({
+ cwd,
+ registryFile: "registry/registry.json",
+ })
+
+ expect(registry.items[0].files?.[0].path).toBe("components/button.tsx")
+ })
+
+ it("preserves registry dependencies for install-time resolution", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/blocks/registry.json"],
+ items: [],
+ }),
+ "registry/blocks/registry.json": JSON.stringify({
+ name: "example-blocks",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "login-form",
+ type: "registry:block",
+ registryDependencies: [
+ "button",
+ "@acme/button",
+ "https://example.com/r/input.json",
+ ],
+ },
+ ],
+ }),
+ })
+
+ const result = await readRegistryWithIncludes("registry.json", { cwd })
+
+ expect(result.registry.items[0].registryDependencies).toEqual([
+ "button",
+ "@acme/button",
+ "https://example.com/r/input.json",
+ ])
+ })
+
+ it("resolves a local registry catalog for dynamic registry routes", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json"],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ "registry/ui/button.tsx": "export function Button() {}",
+ })
+
+ const registry = await loadRegistry({ cwd })
+
+ expect(registry).toMatchObject({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ files: [
+ {
+ path: "registry/ui/button.tsx",
+ },
+ ],
+ },
+ ],
+ })
+ expect(registry).not.toHaveProperty("include")
+ expect(registry.items[0].files?.[0]).not.toHaveProperty("content")
+ })
+
+ it("resolves a local registry item for dynamic item routes", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json"],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ "registry/ui/button.tsx": "export function Button() {}",
+ })
+
+ const item = await loadRegistryItem("button", {
+ cwd,
+ })
+
+ expect(item).toMatchObject({
+ $schema: "https://ui.shadcn.com/schema/registry-item.json",
+ name: "button",
+ files: [
+ {
+ path: "registry/ui/button.tsx",
+ content: "export function Button() {}",
+ },
+ ],
+ })
+ })
+
+ it("reports missing item files with item and source context", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ include: ["registry/ui/registry.json"],
+ }),
+ "registry/ui/registry.json": JSON.stringify({
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ })
+
+ await expect(loadRegistryItem("button", { cwd })).rejects.toThrow(
+ 'Failed to read file "button.tsx" for registry item "button"'
+ )
+ await expect(loadRegistryItem("button", { cwd })).rejects.toBeInstanceOf(
+ RegistryLocalFileError
+ )
+ })
+
+ it("uses the selected item source when duplicate names exist in a flat registry", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ items: [
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "missing-button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ {
+ name: "button",
+ type: "registry:ui",
+ files: [
+ {
+ path: "button.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
+ ],
+ }),
+ "button.tsx": "export function Button() {}",
+ })
+
+ await expect(loadRegistryItem("button", { cwd })).rejects.toThrow(
+ "registry.json items[0]"
+ )
+ })
+
+ it("throws a typed error when a registry item is not found", async () => {
+ const cwd = await createFixture({
+ "registry.json": JSON.stringify({
+ name: "example",
+ homepage: "https://example.com",
+ items: [],
+ }),
+ })
+
+ await expect(loadRegistryItem("button", { cwd })).rejects.toBeInstanceOf(
+ RegistryItemNotFoundError
+ )
+ await expect(loadRegistryItem("button", { cwd })).rejects.toMatchObject({
+ code: RegistryErrorCode.NOT_FOUND,
+ itemName: "button",
+ })
+ })
+})
+
+async function createFixture(files: Record) {
+ const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-registry-"))
+
+ await Promise.all(
+ Object.entries(files).map(async ([filePath, content]) => {
+ const targetPath = path.join(cwd, filePath)
+ await fs.mkdir(path.dirname(targetPath), { recursive: true })
+ await fs.writeFile(targetPath, content)
+ })
+ )
+
+ return cwd
+}
diff --git a/packages/shadcn/src/registry/loader.ts b/packages/shadcn/src/registry/loader.ts
new file mode 100644
index 00000000000..6fa7d37fb49
--- /dev/null
+++ b/packages/shadcn/src/registry/loader.ts
@@ -0,0 +1,648 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+import {
+ RegistryItemNotFoundError,
+ RegistryLocalFileError,
+ RegistryParseError,
+ RegistryValidationError,
+} from "@/src/registry/errors"
+import { isUrl } from "@/src/registry/utils"
+import {
+ registryChunkSchema,
+ registryItemSchema,
+ type Registry,
+ type RegistryItem,
+} from "@/src/schema"
+import { z } from "zod"
+
+type RegistryChunk = z.infer
+
+const MAX_INCLUDE_DEPTH = 32
+
+type RegistryItemSource = {
+ registryFile: string
+ registryDir: string
+ itemIndex: number
+}
+
+type RegistryLoadResult = {
+ registry: Registry
+ itemSources: Map
+ itemSourcesByItem: Map
+ usesInclude: boolean
+}
+
+export type LoadRegistryOptions = {
+ cwd?: string
+ registryFile?: string
+}
+
+export async function loadRegistry(options?: LoadRegistryOptions) {
+ const { cwd, registryFile } = resolveLoadRegistryOptions(options)
+ const result = await readRegistryWithIncludes(registryFile, { cwd })
+ const rootDir = getRegistryRootDir(result, cwd, registryFile)
+
+ return createRegistryCatalog(result, rootDir, cwd)
+}
+
+export async function loadRegistryItem(
+ itemName: string,
+ options?: LoadRegistryOptions
+) {
+ const { cwd, registryFile } = resolveLoadRegistryOptions(options)
+ const result = await readRegistryWithIncludes(registryFile, { cwd })
+ const item = result.registry.items.find((item) => item.name === itemName)
+
+ if (!item) {
+ throw new RegistryItemNotFoundError(itemName)
+ }
+
+ const rootDir = getRegistryRootDir(result, cwd, registryFile)
+
+ return createRegistryItem(item, result, rootDir, cwd)
+}
+
+export async function readRegistryWithIncludes(
+ registryFile: string,
+ options: {
+ cwd: string
+ }
+) {
+ const rootFile = path.resolve(options.cwd, registryFile)
+ const content = await readRegistryJson(rootFile)
+ const rootRegistry = parseRegistry(content, rootFile)
+ validateRootRegistry(rootRegistry, rootFile)
+ const context = {
+ cwd: path.resolve(options.cwd),
+ itemSources: new Map(),
+ itemSourcesByItem: new Map(),
+ firstIncludedFrom: new Map(),
+ }
+ const usesInclude = !!rootRegistry.include?.length
+
+ if (!usesInclude) {
+ rootRegistry.items.forEach((item, itemIndex) => {
+ const source = {
+ registryFile: rootFile,
+ registryDir: context.cwd,
+ itemIndex,
+ }
+ context.itemSources.set(item.name, source)
+ context.itemSourcesByItem.set(item, source)
+ })
+
+ return {
+ registry: rootRegistry,
+ itemSources: context.itemSources,
+ itemSourcesByItem: context.itemSourcesByItem,
+ usesInclude,
+ }
+ }
+
+ if (path.basename(rootFile) !== "registry.json") {
+ throw new RegistryValidationError(
+ `Invalid registry file at ${rootFile}: registries that use include must be named registry.json.`,
+ { registryFile: rootFile }
+ )
+ }
+
+ const result = await readRegistryFile(rootFile, rootRegistry, context, [])
+
+ validateDuplicateItems(result.items, context.itemSourcesByItem)
+
+ const { include, ...registry } = result
+ validateRootRegistry(registry, rootFile)
+
+ return {
+ registry,
+ itemSources: context.itemSources,
+ itemSourcesByItem: context.itemSourcesByItem,
+ usesInclude,
+ }
+}
+
+export function createRegistryCatalog(
+ result: RegistryLoadResult,
+ rootDir: string,
+ fallbackDir: string
+) {
+ return {
+ ...result.registry,
+ items: result.registry.items.map((item) =>
+ stripRegistryItemFileContent(
+ rewriteRegistryItemFilePaths(
+ item,
+ result.itemSourcesByItem,
+ rootDir,
+ fallbackDir
+ )
+ )
+ ),
+ }
+}
+
+export async function createRegistryItem(
+ item: RegistryItem,
+ result: RegistryLoadResult,
+ rootDir: string,
+ fallbackDir: string
+) {
+ const registryItem = {
+ ...rewriteRegistryItemFilePaths(
+ item,
+ result.itemSourcesByItem,
+ rootDir,
+ fallbackDir
+ ),
+ $schema: "https://ui.shadcn.com/schema/registry-item.json",
+ }
+
+ for (let index = 0; index < (item.files?.length ?? 0); index++) {
+ const sourceFile = item.files?.[index]
+ const file = registryItem.files?.[index]
+ if (!file || !sourceFile) {
+ continue
+ }
+
+ const source = result.itemSourcesByItem.get(item)
+ const sourcePath = getRegistryItemFileSourceForItem(
+ item,
+ sourceFile.path,
+ result.itemSourcesByItem,
+ fallbackDir
+ )
+ file.content = await readRegistryItemFileContent(
+ item.name,
+ sourceFile.path,
+ sourcePath,
+ source
+ )
+ }
+
+ return registryItemSchema.parse(registryItem)
+}
+
+async function readRegistryItemFileContent(
+ itemName: string,
+ filePath: string,
+ sourcePath: string,
+ source: RegistryItemSource | undefined
+) {
+ try {
+ return await fs.readFile(sourcePath, "utf-8")
+ } catch (error) {
+ throw new RegistryLocalFileError(sourcePath, error, {
+ message: `Failed to read file "${filePath}" for registry item "${itemName}" (${formatItemSource(
+ source
+ )}). Expected file at ${sourcePath}.`,
+ context: {
+ itemName,
+ itemFilePath: filePath,
+ sourcePath,
+ },
+ suggestion:
+ "Make sure the file path is relative to the registry.json file that declares the item.",
+ })
+ }
+}
+
+function rewriteRegistryItemFilePaths(
+ item: RegistryItem,
+ itemSourcesByItem: Map,
+ rootDir: string,
+ fallbackDir: string
+) {
+ return {
+ ...item,
+ files: item.files?.map((file) => ({
+ ...file,
+ path: getRegistryItemFileRootPathForItem(
+ item,
+ file.path,
+ itemSourcesByItem,
+ rootDir,
+ fallbackDir
+ ),
+ })),
+ }
+}
+
+function stripRegistryItemFileContent(item: RegistryItem) {
+ return {
+ ...item,
+ files: item.files?.map(({ content, ...file }) => file),
+ }
+}
+
+export function getRegistryItemFileSource(
+ itemName: string,
+ filePath: string,
+ itemSources: Map,
+ fallbackDir: string
+) {
+ const source = itemSources.get(itemName)
+ return path.resolve(source?.registryDir ?? fallbackDir, filePath)
+}
+
+function getRegistryItemFileSourceForItem(
+ item: RegistryItem,
+ filePath: string,
+ itemSourcesByItem: Map,
+ fallbackDir: string
+) {
+ const source = itemSourcesByItem.get(item)
+ return path.resolve(source?.registryDir ?? fallbackDir, filePath)
+}
+
+export function getRegistryItemFileRootPath(
+ itemName: string,
+ filePath: string,
+ itemSources: Map,
+ rootDir: string,
+ fallbackDir: string
+) {
+ const sourcePath = getRegistryItemFileSource(
+ itemName,
+ filePath,
+ itemSources,
+ fallbackDir
+ )
+
+ return path.relative(rootDir, sourcePath).split(path.sep).join("/")
+}
+
+function getRegistryItemFileRootPathForItem(
+ item: RegistryItem,
+ filePath: string,
+ itemSourcesByItem: Map,
+ rootDir: string,
+ fallbackDir: string
+) {
+ const sourcePath = getRegistryItemFileSourceForItem(
+ item,
+ filePath,
+ itemSourcesByItem,
+ fallbackDir
+ )
+
+ return path.relative(rootDir, sourcePath).split(path.sep).join("/")
+}
+
+async function readRegistryFile(
+ registryFile: string,
+ registry: RegistryChunk,
+ context: {
+ cwd: string
+ itemSources: Map
+ itemSourcesByItem: Map
+ firstIncludedFrom: Map
+ },
+ chain: string[]
+): Promise {
+ validateRegistryFileWithinRoot(registryFile, context.cwd)
+
+ if (chain.length >= MAX_INCLUDE_DEPTH) {
+ throw new RegistryValidationError(
+ `Registry include tree is too deep at ${registryFile}. The maximum include depth is ${MAX_INCLUDE_DEPTH}.`,
+ {
+ registryFile,
+ context: {
+ maxDepth: MAX_INCLUDE_DEPTH,
+ },
+ suggestion:
+ "Flatten part of the registry include tree or reduce nested include depth.",
+ }
+ )
+ }
+
+ if (chain.includes(registryFile)) {
+ throw new RegistryValidationError(
+ formatIncludeCycle([...chain, registryFile]),
+ {
+ registryFile,
+ }
+ )
+ }
+
+ const includedFrom = chain.at(-1) ?? registryFile
+ const existingSource = context.firstIncludedFrom.get(registryFile)
+ if (existingSource) {
+ throw new RegistryValidationError(
+ `Registry file included more than once: ${registryFile}.\n` +
+ ` - first included from ${existingSource}\n` +
+ ` - included again from ${includedFrom}\n` +
+ `Each registry.json file can only appear once in the resolved include tree. Remove one include or move shared items into a single included registry.json.`,
+ {
+ registryFile,
+ context: {
+ firstSource: existingSource,
+ secondSource: includedFrom,
+ },
+ }
+ )
+ }
+
+ context.firstIncludedFrom.set(registryFile, includedFrom)
+
+ const nextChain = [...chain, registryFile]
+ const registryDir = path.dirname(registryFile)
+
+ const includedItems: RegistryItem[] = []
+ for (const includePath of registry.include ?? []) {
+ const includedRegistryFile = resolveIncludePath(
+ includePath,
+ registryDir,
+ context.cwd,
+ registryFile
+ )
+ const content = await readRegistryJson(includedRegistryFile)
+ const parsedRegistry = parseRegistry(content, includedRegistryFile)
+ const includedRegistry = await readRegistryFile(
+ includedRegistryFile,
+ parsedRegistry,
+ context,
+ nextChain
+ )
+ includedItems.push(...includedRegistry.items)
+ }
+
+ registry.items.forEach((item, itemIndex) => {
+ validateRegistryItemFiles(item, registryFile, registryDir)
+ context.itemSources.set(item.name, {
+ registryFile,
+ registryDir,
+ itemIndex,
+ })
+ context.itemSourcesByItem.set(item, {
+ registryFile,
+ registryDir,
+ itemIndex,
+ })
+ })
+
+ return {
+ ...registry,
+ items: [...includedItems, ...registry.items],
+ }
+}
+
+async function readRegistryJson(registryFile: string) {
+ try {
+ return await fs.readFile(registryFile, "utf-8")
+ } catch (error) {
+ throw new RegistryLocalFileError(registryFile, error, {
+ message: `Failed to read registry file at ${registryFile}.`,
+ context: { registryFile },
+ suggestion:
+ "Check that the registry.json file exists and that the path is correct.",
+ })
+ }
+}
+
+function parseRegistry(content: string, registryFile: string) {
+ let json: unknown
+ try {
+ json = JSON.parse(content)
+ } catch (error) {
+ throw new RegistryParseError(registryFile, error, {
+ subject: "registry file",
+ context: { registryFile },
+ suggestion:
+ "Fix the JSON syntax in the registry.json file and try again.",
+ })
+ }
+
+ const result = registryChunkSchema.safeParse(json)
+ if (!result.success) {
+ throw new RegistryValidationError(
+ `Invalid registry file at ${registryFile}:\n${formatZodIssues(
+ result.error
+ )}`,
+ {
+ registryFile,
+ cause: result.error,
+ suggestion:
+ "Update the registry.json file so it matches the registry schema.",
+ }
+ )
+ }
+
+ return result.data
+}
+
+function validateRootRegistry(
+ registry: RegistryChunk,
+ registryFile: string
+): asserts registry is Registry {
+ const missingFields = []
+
+ if (!registry.name) {
+ missingFields.push("name")
+ }
+
+ if (!registry.homepage) {
+ missingFields.push("homepage")
+ }
+
+ if (missingFields.length) {
+ throw new RegistryValidationError(
+ `Invalid root registry file at ${registryFile}: root registry.json must define ${missingFields
+ .map((field) => `"${field}"`)
+ .join(" and ")}. Included registry.json files may omit these fields.`,
+ { registryFile }
+ )
+ }
+}
+
+function resolveIncludePath(
+ includePath: string,
+ registryDir: string,
+ cwd: string,
+ registryFile: string
+) {
+ if (isUrl(includePath)) {
+ throw new RegistryValidationError(
+ `Invalid include "${includePath}" in ${registryFile}: remote includes are not supported by shadcn build. Use a relative path to a registry.json file in the same repository.`,
+ {
+ registryFile,
+ context: { includePath },
+ }
+ )
+ }
+
+ if (path.isAbsolute(includePath)) {
+ throw new RegistryValidationError(
+ `Invalid include "${includePath}" in ${registryFile}: include paths must be relative. Use a path like "./registry/ui/registry.json".`,
+ {
+ registryFile,
+ context: { includePath },
+ }
+ )
+ }
+
+ if (hasParentTraversal(includePath)) {
+ throw new RegistryValidationError(
+ `Invalid include "${includePath}" in ${registryFile}: include paths cannot use parent-directory traversal. Keep included registry.json files inside the registry root.`,
+ {
+ registryFile,
+ context: { includePath },
+ }
+ )
+ }
+
+ if (path.basename(includePath) !== "registry.json") {
+ throw new RegistryValidationError(
+ `Invalid include "${includePath}" in ${registryFile}: include paths must explicitly reference a registry.json file. Use a path like "./registry/ui/registry.json".`,
+ {
+ registryFile,
+ context: { includePath },
+ }
+ )
+ }
+
+ const resolvedPath = path.resolve(registryDir, includePath)
+ validateRegistryFileWithinRoot(resolvedPath, cwd)
+
+ return resolvedPath
+}
+
+function validateRegistryFileWithinRoot(registryFile: string, cwd: string) {
+ if (!isPathInside(registryFile, cwd)) {
+ throw new RegistryValidationError(
+ `Invalid registry file at ${registryFile}: registry includes must stay inside ${cwd}.`,
+ {
+ registryFile,
+ context: { cwd },
+ }
+ )
+ }
+}
+
+function resolveLoadRegistryOptions(options?: LoadRegistryOptions) {
+ return {
+ cwd: path.resolve(options?.cwd ?? process.cwd()),
+ registryFile: options?.registryFile ?? "registry.json",
+ }
+}
+
+function getRegistryRootDir(
+ result: Pick,
+ cwd: string,
+ registryFile: string
+) {
+ return result.usesInclude
+ ? path.dirname(path.resolve(cwd, registryFile))
+ : cwd
+}
+
+function validateRegistryItemFiles(
+ item: RegistryItem,
+ registryFile: string,
+ registryDir: string
+) {
+ for (const file of item.files ?? []) {
+ if (isUrl(file.path)) {
+ throw new RegistryValidationError(
+ `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: remote file paths are not supported by shadcn build.`,
+ {
+ registryFile,
+ context: { itemName: item.name, filePath: file.path },
+ }
+ )
+ }
+
+ if (path.isAbsolute(file.path)) {
+ throw new RegistryValidationError(
+ `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: file paths must be relative.`,
+ {
+ registryFile,
+ context: { itemName: item.name, filePath: file.path },
+ }
+ )
+ }
+
+ if (hasParentTraversal(file.path)) {
+ throw new RegistryValidationError(
+ `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: file paths cannot use parent-directory traversal.`,
+ {
+ registryFile,
+ context: { itemName: item.name, filePath: file.path },
+ }
+ )
+ }
+
+ const resolvedPath = path.resolve(registryDir, file.path)
+ if (!isPathInside(resolvedPath, registryDir)) {
+ throw new RegistryValidationError(
+ `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: file paths must stay inside the registry chunk directory.`,
+ {
+ registryFile,
+ context: { itemName: item.name, filePath: file.path },
+ }
+ )
+ }
+ }
+}
+
+function validateDuplicateItems(
+ items: RegistryItem[],
+ itemSources: Map
+) {
+ const seen = new Map()
+
+ for (const item of items) {
+ const existing = seen.get(item.name)
+ if (!existing) {
+ seen.set(item.name, item)
+ continue
+ }
+
+ const firstSource = itemSources.get(existing)
+ const secondSource = itemSources.get(item)
+ throw new RegistryValidationError(
+ `Duplicate registry item name "${item.name}". Registry item names must be unique.\n` +
+ ` - ${formatItemSource(firstSource)}\n` +
+ ` - ${formatItemSource(secondSource)}\n` +
+ `Rename one of these items so each name is unique across the resolved registry.`,
+ {
+ context: {
+ itemName: item.name,
+ firstSource: formatItemSource(firstSource),
+ secondSource: formatItemSource(secondSource),
+ },
+ }
+ )
+ }
+}
+
+function hasParentTraversal(filePath: string) {
+ return filePath.split(/[\\/]+/).includes("..")
+}
+
+function isPathInside(filePath: string, root: string) {
+ const relative = path.relative(root, filePath)
+ return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative)
+}
+
+function formatIncludeCycle(chain: string[]) {
+ return `Registry include cycle detected:\n${chain
+ .map((file) => ` - ${file}`)
+ .join("\n")}`
+}
+
+function formatItemSource(source: RegistryItemSource | undefined) {
+ if (!source) {
+ return "unknown source"
+ }
+
+ return `${source.registryFile} items[${source.itemIndex}]`
+}
+
+function formatZodIssues(error: z.ZodError) {
+ return error.errors
+ .map((issue) => {
+ const issuePath = issue.path.length ? issue.path.join(".") : "(root)"
+ return ` - ${issuePath}: ${issue.message}`
+ })
+ .join("\n")
+}
diff --git a/packages/shadcn/src/registry/schema.test.ts b/packages/shadcn/src/registry/schema.test.ts
index e7fa2c9b4ad..808b5d8ae9f 100644
--- a/packages/shadcn/src/registry/schema.test.ts
+++ b/packages/shadcn/src/registry/schema.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"
-import { registryConfigSchema } from "./schema"
+import {
+ registryChunkSchema,
+ registryConfigSchema,
+ registrySchema,
+} from "./schema"
describe("registryConfigSchema", () => {
it("should accept valid registry names starting with @", () => {
@@ -47,3 +51,33 @@ describe("registryConfigSchema", () => {
}
})
})
+
+describe("registrySchema", () => {
+ it("should accept registry chunks with includes", () => {
+ const result = registryChunkSchema.safeParse({
+ include: ["./registry/ui/registry.json"],
+ })
+
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.items).toEqual([])
+ }
+ })
+
+ it("should require name and homepage for root registries", () => {
+ const result = registrySchema.safeParse({
+ include: ["./registry/ui/registry.json"],
+ })
+
+ expect(result.success).toBe(false)
+ })
+
+ it("should reject registries without items or include", () => {
+ const result = registryChunkSchema.safeParse({
+ name: "example",
+ homepage: "https://example.com",
+ })
+
+ expect(result.success).toBe(false)
+ })
+})
diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts
index 9bab2e84fe9..ff24e3f5315 100644
--- a/packages/shadcn/src/registry/schema.ts
+++ b/packages/shadcn/src/registry/schema.ts
@@ -198,11 +198,37 @@ export type RegistryBaseItem = Extract
// Helper type for registry:font items specifically.
export type RegistryFontItem = Extract
-export const registrySchema = z.object({
- name: z.string(),
- homepage: z.string(),
- items: z.array(registryItemSchema),
-})
+const registryBaseSchema = z
+ .object({
+ $schema: z.string().optional(),
+ name: z.string().optional(),
+ homepage: z.string().optional(),
+ include: z.array(z.string()).optional(),
+ items: z.array(registryItemSchema).optional(),
+ })
+ .refine(
+ (registry) =>
+ registry.items !== undefined || registry.include !== undefined,
+ {
+ message: "Registry must define at least one of `items` or `include`.",
+ path: ["items"],
+ }
+ )
+
+export const registryChunkSchema = registryBaseSchema.transform((registry) => ({
+ ...registry,
+ items: registry.items ?? [],
+}))
+
+export const registrySchema = registryChunkSchema.pipe(
+ z.object({
+ $schema: z.string().optional(),
+ name: z.string(),
+ homepage: z.string(),
+ include: z.array(z.string()).optional(),
+ items: z.array(registryItemSchema),
+ })
+)
export type Registry = z.infer