Compare commits

...

5 Commits

Author SHA1 Message Date
github-actions[bot]
072c27fcd5 chore(release): version packages (#10568)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-21 17:57:06 +04:00
shadcn
194dcc4571 docs: update changelog 2026-05-21 17:48:36 +04:00
shadcn
51e3cfaf32 feat(registry): add validate command (#10715)
* feat: implement registry include

* feat: updates

* fix

* refactor: implementation

* fix(registry): correct directory registry json

* fix(registry): stop warning for external registry dependencies

* feat(registry): add validate command
2026-05-21 17:32:34 +04:00
shadcn
c8ab3801ec feat: add include to registry.json (#10708)
* feat: implement registry include

* feat: updates

* fix

* refactor: implementation

* fix(registry): correct directory registry json

* fix(registry): stop warning for external registry dependencies

* fix(registry): address include review feedback
2026-05-21 17:13:04 +04:00
shadcn
731e6dd8a2 fix 2026-05-20 17:27:42 +04:00
31 changed files with 3959 additions and 196 deletions

View File

@@ -1,5 +0,0 @@
---
"shadcn": patch
---
fix failing version derivation test

View File

@@ -11,7 +11,6 @@
"[CLI](/docs/cli)",
"monorepo",
"skills",
"v0",
"javascript",
"blocks",
"figma",

View File

@@ -0,0 +1,120 @@
---
title: May 2026 - Registry Include and Validate
description: Organize and validate source registries.
date: 2026-05-20
---
This release adds two updates for registry authors:
- `include` for composing large source registries from multiple `registry.json`
files.
- `shadcn registry validate` for checking source registries before publishing.
This makes it easier to maintain source and dynamic registries without keeping
one large `registry.json` file by hand.
Registry authors can now organize a large source registry across multiple
`registry.json` files and compose them with `shadcn build`.
```txt /registry.json/
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 {6-7}
{
"$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.
## Validate your registry
You can now validate a source registry before publishing or serving it.
```bash
npx shadcn registry validate
```
Validation runs against the source registry files directly. You do not need to
run `shadcn build` first.
The command checks the root `registry.json`, included registry files, item
schema errors, duplicate item names, include rules, and local item file paths.
Validation reports all actionable errors it can find in one run.
## 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.

View File

@@ -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,7 @@ 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.
## Add a registry.json file
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.
Here's an example `registry.json` file:
```json title="registry.json" showLineNumbers
{
@@ -31,44 +29,204 @@ 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"
}
]
}
]
}
```
## 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.
### Option A: Single registry.json
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
{
"$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"
}
]
},
{
"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 `<HelloWorld />` 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 <Button>Hello World</Button>
{/* 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 `<Button />` component:
```tsx title="components/ui/button.tsx" showLineNumbers
import * as React from "react"
export function Button(props: React.ComponentProps<"button">) {
return (
<button
{...props}
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white"
/>
)
}
```
<Callout className="mt-6">
**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.
</Callout>
```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
### Install the shadcn CLI
You can serve your registry as static JSON files or from dynamic route handlers.
### Option A: Static JSON files
Run the build command to generate static registry JSON files.
```bash
npm install shadcn@latest
npx shadcn@latest build
```
### Add a build script
Add a `registry:build` script to your `package.json` file.
```json title="package.json" showLineNumbers
{
"scripts": {
"registry:build": "shadcn build"
}
}
```
### Run the build script
Run the build script to generate the registry JSON files.
```bash
npm run registry:build
```
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`.
<Callout className="mt-6">
**Note:** By default, the build script will generate the registry JSON files
in `public/r` e.g `public/r/hello-world.json`.
You can change the output directory by passing the `--output` option. See the [shadcn build command](/docs/cli#build) for more information.
**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.
</Callout>
## Serve your registry
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.
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
```
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/button.json`.
## Content negotiation
### Option B: Dynamic route handlers
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 install shadcn
```
Use `loadRegistry` to serve the registry catalog.
```ts title="app/r/registry.json/route.ts" showLineNumbers
import { loadRegistry } from "shadcn/registry"
export async function GET() {
try {
const registry = await loadRegistry()
return Response.json(registry)
} catch (error) {
console.error(error)
return Response.json({ error: "Failed to load registry." }, { status: 500 })
}
}
```
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 }
)
}
}
```
Both loaders resolve `include` before returning JSON, so route handlers can use
the same source `registry.json` structure without running `shadcn build`.
<Accordion type="single" collapsible>
<AccordionItem value="content-negotiation">
<AccordionTrigger>Content negotiation</AccordionTrigger>
<AccordionContent>
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.
<ul>
<li>
<strong>HTML</strong> to browsers — a landing page, documentation, or
marketing site.
</li>
<li>
<strong>JSON</strong> to the <code>shadcn</code> CLI — an installable
registry item.
</li>
<li>
<strong>Markdown</strong> to AI agents and LLMs — a machine-readable version
of your content.
</li>
</ul>
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.
<ul>
<li>
<strong>Branded Registry URLs</strong>:{" "}
<code>shadcn add https://ui.example.com</code>
</li>
<li>
<strong>Shorter URLs</strong>: Users type your domain root, not{" "}
<code>/r/</code> or <code>/registry/</code> sub-paths.
</li>
<li>
<strong>Easy Mnemonics</strong>: Easier for users to remember and share your
registry.
</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
## 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.
### Share namespace setup instructions
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.
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`.
They can add the namespace with the CLI.
```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 @acme/button
```
### 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 `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`.
- 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/new-york/hello-world/hello-world"`
- **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.
## Install using 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 add http://localhost:3000/r/hello-world.json
```
See the [Namespaced
Registries](/docs/registry/namespace) docs for more information on
how to install registry items from a namespaced registry.

View File

@@ -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"

View File

@@ -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).

View File

@@ -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.

View File

@@ -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"]

View File

@@ -77,7 +77,7 @@
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"server-only": "^0.0.1",
"shadcn": "4.7.0",
"shadcn": "4.8.0",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"swr": "^2.3.6",

View File

@@ -1169,18 +1169,16 @@
"url": "https://ui.turbopills.com/r/{name}.json",
"description": "Beautiful, accessible, and customizable React components for your telehealth applications."
},
{
"name": "@nexus-labs",
"homepage": "https://nexus-ui.com",
"url": "https://nexus-ui.com/r/{name}.json",
"description": "Motion-native animated components for Next.js — backgrounds, heroes, inputs, carousels, and more. Open source. Copy-ready TypeScript."
},
{
"name": "@wensity",
"homepage": "https://wensity.com",
"url": "https://raw.githubusercontent.com/ksparth12/wensity-shadcn-registry/main/{name}.json",
"description": "Motion-rich React components for AI interfaces, SaaS blocks, and cinematic interactions. Free Wensity components only."
},
{
"name": "@nexus-labs",
"homepage": "https://nexus-ui.com",
"url": "https://nexus-ui.com/r/{name}.json",
"registry": "https://nexus-ui.com/registry.json",
"description": "Production-ready components and full-page Next.js templates with cinematic Framer Motion animations, Tailwind CSS v4, and TypeScript."
}
}
]

View File

@@ -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"] }]
}

View File

@@ -1366,17 +1366,17 @@
"logo": "<svg xmlns='http://www.w3.org/2000/svg' version='1.0' width='597.000000pt' height='525.000000pt' viewBox='0 0 597.000000 525.000000' preserveAspectRatio='xMidYMid meet'><g transform='translate(0.000000,525.000000) scale(0.100000,-0.100000)' fill='var(--foreground, currentColor)' stroke='none'><path d='M1816 5223 l-1809 -3 6 -108 c24 -383 241 -748 579 -969 113 -74 205 -118 338 -162 178 -58 237 -64 678 -70 l404 -6 14 33 c56 136 213 367 364 538 283 320 747 605 1163 716 48 12 87 26 87 30 0 5 -3 7 -7 6 -5 -2 -822 -4 -1817 -5z'/><path d='M4630 5209 c-983 -101 -1863 -612 -2297 -1334 -249 -414 -394 -925 -462 -1625 -28 -287 -29 -310 -19 -304 4 3 8 12 8 20 0 19 87 192 163 324 413 718 1068 1274 1797 1522 228 78 466 118 703 118 506 0 811 110 1079 389 135 139 217 274 283 462 43 123 60 208 70 342 l7 97 -623 -1 c-343 -1 -662 -6 -709 -10z'/><path d='M3876 3584 c-339 -138 -733 -410 -1041 -719 -347 -347 -596 -694 -840 -1170 -188 -367 -356 -851 -435 -1250 -31 -157 -60 -344 -60 -389 l0 -39 183 7 c224 8 336 20 497 52 676 135 1179 580 1326 1174 19 74 56 257 83 405 94 515 222 1211 256 1390 50 268 97 545 93 555 -2 4 -29 -2 -62 -16z'/></g></svg>"
},
{
"name": "@next-ui",
"homepage": "https://nexus-ui.com",
"url": "https://nexus-ui.com/r/{name}.json",
"description": "Motion-native animated components for Next.js — backgrounds, heroes, inputs, carousels, and more. Open source. Copy-ready TypeScript.",
"logo": "<svg width='32' height='32' viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'><rect width='64' height='64' rx='12' fill='#0a0a0a'/><circle cx='32' cy='10' r='3.5' fill='white'/><circle cx='9' cy='52' r='3.5' fill='white'/><circle cx='55' cy='52' r='3.5' fill='white'/><path d='M32 13.5 L32 33 M12 51 L32 33 M52 51 L32 33' stroke='white' stroke-width='2.5' stroke-linecap='round'/><circle cx='32' cy='33' r='5.5' fill='oklch(0.72 0.18 250)'/><circle cx='32' cy='33' r='5.5' fill='none' stroke='white' stroke-width='1.25'/></svg>"
}
"name": "@nexus-labs",
"homepage": "https://nexus-ui.com",
"url": "https://nexus-ui.com/r/{name}.json",
"description": "Motion-native animated components for Next.js — backgrounds, heroes, inputs, carousels, and more. Open source. Copy-ready TypeScript.",
"logo": "<svg width='32' height='32' viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'><rect width='64' height='64' rx='12' fill='#0a0a0a'/><circle cx='32' cy='10' r='3.5' fill='white'/><circle cx='9' cy='52' r='3.5' fill='white'/><circle cx='55' cy='52' r='3.5' fill='white'/><path d='M32 13.5 L32 33 M12 51 L32 33 M52 51 L32 33' stroke='white' stroke-width='2.5' stroke-linecap='round'/><circle cx='32' cy='33' r='5.5' fill='oklch(0.72 0.18 250)'/><circle cx='32' cy='33' r='5.5' fill='none' stroke='white' stroke-width='1.25'/></svg>"
},
{
"name": "@wensity",
"homepage": "https://wensity.com",
"url": "https://raw.githubusercontent.com/ksparth12/wensity-shadcn-registry/main/{name}.json",
"description": "Motion-rich React components for AI interfaces, SaaS blocks, and cinematic interactions. Free Wensity components only.",
"logo": "<svg width='512' height='512' viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'><rect width='512' height='512' rx='112' fill='var(--foreground)'/><path d='M112 152h58l36 160 43-160h49l43 160 36-160h58l-62 208h-62l-38-141-38 141h-62L112 152Z' fill='var(--background)'/></svg>"
}
]
]

View File

@@ -1,5 +1,17 @@
# @shadcn/ui
## 4.8.0
### Minor Changes
- [#10715](https://github.com/shadcn-ui/ui/pull/10715) [`51e3cfaf32faeff2589e5c74d81ffd109f509e93`](https://github.com/shadcn-ui/ui/commit/51e3cfaf32faeff2589e5c74d81ffd109f509e93) Thanks [@shadcn](https://github.com/shadcn)! - add shadcn registry validate command
- [#10708](https://github.com/shadcn-ui/ui/pull/10708) [`c8ab3801ecf97c0350ac0234a25e61f19ccaba62`](https://github.com/shadcn-ui/ui/commit/c8ab3801ecf97c0350ac0234a25e61f19ccaba62) Thanks [@shadcn](https://github.com/shadcn)! - add include to registry.json
### Patch Changes
- [#10567](https://github.com/shadcn-ui/ui/pull/10567) [`1c4a53a37adeba36dbd5c07980c5bb6d295cea9e`](https://github.com/shadcn-ui/ui/commit/1c4a53a37adeba36dbd5c07980c5bb6d295cea9e) Thanks [@shadcn](https://github.com/shadcn)! - fix failing version derivation test
## 4.7.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "shadcn",
"version": "4.7.0",
"version": "4.8.0",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"

View File

@@ -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<string, string>) {
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
}

View File

@@ -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) {

View File

@@ -1,7 +1,9 @@
import { add } from "@/src/commands/registry/add"
import { validate } from "@/src/commands/registry/validate"
import { Command } from "commander"
export const registry = new Command()
.name("registry")
.description("manage registries")
.addCommand(add)
.addCommand(validate)

View File

@@ -0,0 +1,137 @@
import * as fs from "fs/promises"
import { tmpdir } from "os"
import * as path from "path"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { validate } from "./validate"
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
success: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
describe("registry validate command", () => {
beforeEach(() => {
vi.clearAllMocks()
process.exitCode = undefined
})
it("prints success with checked counts", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [],
}),
})
await validate.parseAsync(["registry.json", "--cwd", cwd], {
from: "user",
})
const validationSpinner = vi.mocked(spinner).mock.results[0].value
const summarySpinner = vi.mocked(spinner).mock.results[1].value
expect(validationSpinner.succeed).toHaveBeenCalledWith("Registry is valid.")
expect(spinner).toHaveBeenCalledWith("Checked 1 registry file and 0 items.")
expect(summarySpinner.succeed).toHaveBeenCalled()
expect(logger.log).toHaveBeenCalledWith(" - registry.json")
expect(
vi.mocked(logger.log).mock.calls.map(([message]) => message)
).toEqual([" - registry.json"])
expect(process.exitCode).toBeUndefined()
})
it("prints grouped diagnostics and sets a failing exit code", 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: "missing.tsx",
type: "registry:ui",
},
],
},
],
}),
})
await validate.parseAsync(["registry.json", "--cwd", cwd], {
from: "user",
})
const validationSpinner = vi.mocked(spinner).mock.results[0].value
expect(validationSpinner.fail).toHaveBeenCalledWith(
"Registry validation failed."
)
expect(spinner).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith(
" Checked 2 registry files and 1 item."
)
expect(logger.log).toHaveBeenCalledWith(" - registry.json")
expect(logger.log).toHaveBeenCalledWith(" - components/ui/registry.json")
expect(logger.log).toHaveBeenCalledWith("components/ui/registry.json")
expect(logger.error).toHaveBeenCalledWith(
' - items[0] "button" file "missing.tsx": File "missing.tsx" was not found or could not be read.'
)
expect(
vi.mocked(logger.log).mock.calls.map(([message]) => message)
).toEqual([
" Checked 2 registry files and 1 item.",
" - registry.json",
" - components/ui/registry.json",
"components/ui/registry.json",
" Make sure the file path is relative to the registry.json file that declares the item.",
])
expect(process.exitCode).toBe(1)
})
})
async function createFixture(files: Record<string, string>) {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-command-"))
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
}

View File

@@ -0,0 +1,168 @@
import * as path from "path"
import { validateRegistry } from "@/src/registry/validate"
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"
import { z } from "zod"
const validateOptionsSchema = z.object({
cwd: z.string(),
registryFile: z.string(),
})
export const validate = new Command()
.name("validate")
.description("validate a shadcn registry")
.argument("[registry]", "path to registry.json file", "./registry.json")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (registryFile: string, opts) => {
let validationSpinner: ReturnType<typeof spinner> | undefined
try {
const options = validateOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
registryFile,
})
validationSpinner = spinner("Validating registry.").start()
const report = await validateRegistry(options)
printRegistryValidationReport(report, validationSpinner)
if (!report.valid) {
process.exitCode = 1
}
} catch (error) {
validationSpinner?.fail("Registry validation failed.")
logger.break()
handleError(error)
}
})
function printRegistryValidationReport(
report: Awaited<ReturnType<typeof validateRegistry>>,
validationSpinner: ReturnType<typeof spinner>
) {
if (report.valid) {
validationSpinner.succeed("Registry is valid.")
printRegistryValidationStats(report, { success: true })
return
}
validationSpinner.fail("Registry validation failed.")
printRegistryValidationStats(report)
logger.break()
for (const [registryFile, diagnostics] of Array.from(
groupDiagnostics(report)
)) {
logger.log(highlighter.info(formatPath(registryFile, report.cwd)))
for (const diagnostic of diagnostics) {
logger.error(` - ${formatDiagnostic(diagnostic)}`)
if (diagnostic.suggestion) {
logger.log(` ${diagnostic.suggestion}`)
}
}
logger.break()
}
}
function printRegistryValidationStats(
report: Awaited<ReturnType<typeof validateRegistry>>,
options: {
success?: boolean
} = {}
) {
const message = `Checked ${formatCount(
report.registryFiles,
"registry file",
"registry files"
)} and ${formatCount(report.items, "item", "items")}.`
if (options.success) {
printSuccess(message)
} else {
logger.log(` ${message}`)
}
for (const registryFile of report.registryFilePaths) {
logger.log(` - ${formatPath(registryFile, report.cwd)}`)
}
}
function groupDiagnostics(
report: Awaited<ReturnType<typeof validateRegistry>>
) {
const groups = new Map<string, typeof report.diagnostics>()
for (const diagnostic of report.diagnostics) {
const diagnostics = groups.get(diagnostic.registryFile) ?? []
diagnostics.push(diagnostic)
groups.set(diagnostic.registryFile, diagnostics)
}
return groups
}
function formatDiagnostic(
diagnostic: Awaited<
ReturnType<typeof validateRegistry>
>["diagnostics"][number]
) {
const context = []
if (diagnostic.itemIndex !== undefined) {
context.push(`items[${diagnostic.itemIndex}]`)
}
if (diagnostic.itemName) {
context.push(`"${diagnostic.itemName}"`)
}
if (diagnostic.includePath) {
context.push(`include "${diagnostic.includePath}"`)
}
if (diagnostic.filePath) {
context.push(`file "${diagnostic.filePath}"`)
}
if (!context.length) {
return diagnostic.message
}
return `${context.join(" ")}: ${diagnostic.message}`
}
function formatPath(filePath: string, cwd: string) {
const relativePath = path.relative(cwd, filePath)
if (
relativePath &&
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath)
) {
return relativePath.split(path.sep).join("/")
}
if (!relativePath) {
return "."
}
return filePath
}
function printSuccess(message: string) {
spinner(message).succeed()
}
function formatCount(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}

View File

@@ -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"

View File

@@ -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.",
})
}
}

View File

@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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,

View File

@@ -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,

View File

@@ -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<string, string> = {
"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<string, string>) {
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
}

View File

@@ -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<typeof registryChunkSchema>
const MAX_INCLUDE_DEPTH = 32
type RegistryItemSource = {
registryFile: string
registryDir: string
itemIndex: number
}
type RegistryLoadResult = {
registry: Registry
itemSources: Map<string, RegistryItemSource>
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
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<string, RegistryItemSource>(),
itemSourcesByItem: new Map<RegistryItem, RegistryItemSource>(),
firstIncludedFrom: new Map<string, string>(),
}
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<RegistryItem, RegistryItemSource>,
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<string, RegistryItemSource>,
fallbackDir: string
) {
const source = itemSources.get(itemName)
return path.resolve(source?.registryDir ?? fallbackDir, filePath)
}
function getRegistryItemFileSourceForItem(
item: RegistryItem,
filePath: string,
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>,
fallbackDir: string
) {
const source = itemSourcesByItem.get(item)
return path.resolve(source?.registryDir ?? fallbackDir, filePath)
}
export function getRegistryItemFileRootPath(
itemName: string,
filePath: string,
itemSources: Map<string, RegistryItemSource>,
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<RegistryItem, RegistryItemSource>,
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<string, RegistryItemSource>
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
firstIncludedFrom: Map<string, string>
},
chain: string[]
): Promise<RegistryChunk> {
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<RegistryLoadResult, "usesInclude">,
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<RegistryItem, RegistryItemSource>
) {
const seen = new Map<string, RegistryItem>()
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")
}

View File

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

View File

@@ -198,11 +198,37 @@ export type RegistryBaseItem = Extract<RegistryItem, { type: "registry:base" }>
// Helper type for registry:font items specifically.
export type RegistryFontItem = Extract<RegistryItem, { type: "registry:font" }>
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<typeof registrySchema>

View File

@@ -0,0 +1,594 @@
import * as fs from "fs/promises"
import { tmpdir } from "os"
import * as path from "path"
import { describe, expect, it } from "vitest"
import { validateRegistry } from "./validate"
describe("validateRegistry", () => {
it("validates a buildable source registry with 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",
registryDependencies: [
"input",
"@acme/dialog",
"https://example.com/r/card.json",
],
files: [
{
path: "button.tsx",
type: "registry:ui",
},
],
},
],
}),
"components/ui/button.tsx": "export function Button() {}",
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(true)
expect(report.registryFiles).toBe(2)
expect(
report.registryFilePaths.map((filePath) => path.relative(cwd, filePath))
).toEqual(["registry.json", path.join("components", "ui", "registry.json")])
expect(report.items).toBe(1)
expect(report.diagnostics).toEqual([])
})
it("validates an empty source registry", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(true)
expect(report.registryFiles).toBe(1)
expect(report.items).toBe(0)
})
it("preserves cwd-relative files for legacy single-file registries", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [
{
name: "button",
type: "registry:ui",
files: [
{
path: "button.tsx",
type: "registry:ui",
},
],
},
],
}),
"button.tsx": "export function Button() {}",
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(true)
expect(report.diagnostics).toEqual([])
})
it("collects independent diagnostics across include branches", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: ["components/ui.json", "hooks/registry.json"],
items: [
{
name: "button",
type: "registry:ui",
},
],
}),
"hooks/registry.json": JSON.stringify({
items: [
{
name: "button",
type: "registry:hook",
files: [
{
path: "missing.ts",
type: "registry:hook",
},
],
},
],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual(
expect.arrayContaining([
'Include "components/ui.json" must explicitly reference a registry.json file.',
expect.stringContaining('Duplicate registry item name "button"'),
'File "missing.ts" was not found or could not be read.',
])
)
})
it("continues validating valid items when another item is invalid", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [
{
name: "button",
type: "registry:ui",
files: [
{
path: "missing.tsx",
type: "registry:ui",
},
],
},
{
name: "brand-font",
type: "registry:font",
},
],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.items).toBe(2)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
itemIndex: 0,
itemName: "button",
message: 'File "missing.tsx" was not found or could not be read.',
}),
expect.objectContaining({
itemIndex: 1,
itemName: "brand-font",
message: "font: Required",
}),
])
)
})
it("reports all root-level issues for an empty object", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual(
expect.arrayContaining([
'Root registry.json must define "name".',
'Root registry.json must define "homepage".',
"Registry must define at least one of `items` or `include`.",
])
)
})
it("filters internal registry item types from item type diagnostics", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [
{
name: "button",
type: "registry:unknown",
},
],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("Invalid registry item type"),
}),
])
)
expect(
report.diagnostics.some(
(diagnostic) =>
diagnostic.message.includes("registry:example") ||
diagnostic.message.includes("registry:internal")
)
).toBe(false)
})
it("requires the root registry file to be named registry.json", async () => {
const cwd = await createFixture({
"registry.flat.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.flat.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "Root source registry file must be named registry.json.",
}),
])
)
})
it("reports missing root registry files as validation diagnostics", async () => {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-"))
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.registryFiles).toBe(1)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "Registry file was not found or could not be read.",
}),
])
)
})
it("reports include cycles", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: ["registry.json"],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("Registry include cycle detected"),
}),
])
)
})
it("reports include paths that are remote, absolute, or parent-traversing", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: [
"https://example.com/registry.json",
path.join(cwdRoot(), "registry.json"),
"../registry.json",
],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual(
expect.arrayContaining([
'Remote include "https://example.com/registry.json" is not supported.',
expect.stringContaining("must be relative"),
'Include "../registry.json" cannot use parent-directory traversal.',
])
)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
includePath: "../registry.json",
suggestion:
"Registry includes must descend from the including chunk. Move shared registries into the registry root and include them from there.",
}),
])
)
})
it("reports root registry files outside cwd", async () => {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-"))
const outside = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [],
}),
})
const report = await validateRegistry({
cwd,
registryFile: path.relative(cwd, path.join(outside, "registry.json")),
})
expect(report.valid).toBe(false)
expect(report.registryFiles).toBe(0)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(
"Root registry file must stay inside"
),
}),
])
)
})
it("reports include trees that are too deep", async () => {
const files: Record<string, string> = {}
const depth = 33
for (let index = 0; index <= depth; index++) {
const filePath =
index === 0
? "registry.json"
: path.join(...getIncludeSegments(index), "registry.json")
const nextPath =
index === depth
? undefined
: path.join(...getIncludeSegments(index + 1), "registry.json")
files[filePath] = JSON.stringify({
...(index === 0
? {
name: "example",
homepage: "https://example.com",
}
: {}),
...(nextPath
? { include: [path.relative(path.dirname(filePath), nextPath)] }
: { items: [] }),
})
}
const cwd = await createFixture(files)
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("Registry include tree is too deep"),
}),
])
)
})
it("reports registry files included through multiple branches", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: [
"components/registry.json",
"components/shared/registry.json",
],
}),
"components/registry.json": JSON.stringify({
include: ["shared/registry.json"],
}),
"components/shared/registry.json": JSON.stringify({
items: [],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.registryFiles).toBe(3)
expect(
report.registryFilePaths.map((filePath) => path.relative(cwd, filePath))
).toEqual([
"registry.json",
path.join("components", "registry.json"),
path.join("components", "shared", "registry.json"),
])
expect(report.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(
"Registry file included more than once"
),
}),
])
)
})
it("reports missing root registry metadata", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
items: [],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual(
expect.arrayContaining([
'Root registry.json must define "name".',
'Root registry.json must define "homepage".',
])
)
})
it("reports invalid JSON and missing includes without validating dependency names", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: ["components/ui/registry.json", "hooks/registry.json"],
items: [
{
name: "card",
type: "registry:ui",
registryDependencies: ["input"],
},
],
}),
"components/ui/registry.json": "{",
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.registryFiles).toBe(3)
expect(
report.registryFilePaths.map((filePath) => path.relative(cwd, filePath))
).toEqual([
"registry.json",
path.join("components", "ui", "registry.json"),
path.join("hooks", "registry.json"),
])
expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual(
expect.arrayContaining([
"Registry file contains invalid JSON.",
"Registry file was not found or could not be read.",
])
)
expect(
report.diagnostics.some((diagnostic) =>
diagnostic.message.includes("input")
)
).toBe(false)
})
it("reports remote and parent-traversing item file paths", 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: "https://example.com/button.tsx",
type: "registry:ui",
},
{
path: "../shared/button.tsx",
type: "registry:ui",
},
],
},
],
}),
})
const report = await validateRegistry({
cwd,
registryFile: "registry.json",
})
expect(report.valid).toBe(false)
expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual(
expect.arrayContaining([
'File path "https://example.com/button.tsx" cannot be remote.',
'File path "../shared/button.tsx" cannot use parent-directory traversal.',
])
)
})
})
async function createFixture(files: Record<string, string>) {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-"))
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
}
function cwdRoot() {
return path.parse(process.cwd()).root
}
function getIncludeSegments(depth: number) {
return Array.from({ length: depth }, (_, index) => `level-${index + 1}`)
}

View File

@@ -0,0 +1,723 @@
import * as fs from "fs/promises"
import * as path from "path"
import { isUrl } from "@/src/registry/utils"
import {
registryItemSchema,
registryItemTypeSchema,
type RegistryItem,
} from "@/src/schema"
import { z } from "zod"
type RegistryChunk = {
$schema?: string
name?: string
homepage?: string
hasName?: boolean
hasHomepage?: boolean
include?: string[]
items: RegistryItem[]
}
type RegistryItemSource = {
registryFile: string
registryDir: string
itemIndex: number
}
type RegistryValidationDiagnostic = {
registryFile: string
message: string
suggestion?: string
itemName?: string
itemIndex?: number
includePath?: string
filePath?: string
}
type RegistryValidationContext = {
cwd: string
rootFile: string
usesInclude: boolean
diagnostics: RegistryValidationDiagnostic[]
registryFiles: Set<string>
checkedRegistryFiles: Set<string>
itemsChecked: number
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
firstIncludedFrom: Map<string, string>
}
const MAX_INCLUDE_DEPTH = 32
const PUBLIC_REGISTRY_ITEM_TYPES = registryItemTypeSchema.options.filter(
(type) => type !== "registry:example" && type !== "registry:internal"
)
const registryObjectSchema = z.record(z.string(), z.unknown())
const registryIncludeSchema = z.array(z.string())
const registryItemsSchema = z.array(z.unknown())
export async function validateRegistry(options: {
cwd: string
registryFile: string
}) {
const cwd = path.resolve(options.cwd)
const rootFile = path.resolve(cwd, options.registryFile)
const context: RegistryValidationContext = {
cwd,
rootFile,
usesInclude: false,
diagnostics: [],
registryFiles: new Set(),
checkedRegistryFiles: new Set(),
itemsChecked: 0,
itemSourcesByItem: new Map(),
firstIncludedFrom: new Map(),
}
if (path.basename(rootFile) !== "registry.json") {
addDiagnostic(context, {
registryFile: rootFile,
message: "Root source registry file must be named registry.json.",
suggestion:
"Rename the file to registry.json and pass that file to shadcn registry validate.",
})
}
if (!isPathInside(rootFile, cwd)) {
addDiagnostic(context, {
registryFile: rootFile,
message: `Root registry file must stay inside ${formatPath(cwd, cwd)}.`,
suggestion:
"Run the command from the registry root or pass a registry.json file inside --cwd.",
})
return createValidationResult(context, [])
}
const rootRegistry = await readRegistryFile(rootFile, context)
if (!rootRegistry) {
return createValidationResult(context, [])
}
context.usesInclude = !!rootRegistry.include?.length
validateRootRegistry(rootRegistry, rootFile, context)
const items = await collectRegistryItems(rootFile, rootRegistry, context, [])
validateDuplicateItems(items, context)
await validateRegistryItems(items, context)
return createValidationResult(context, items)
}
async function collectRegistryItems(
registryFile: string,
registry: RegistryChunk,
context: RegistryValidationContext,
chain: string[]
): Promise<RegistryItem[]> {
if (chain.length >= MAX_INCLUDE_DEPTH) {
addDiagnostic(context, {
registryFile,
message: `Registry include tree is too deep. The maximum include depth is ${MAX_INCLUDE_DEPTH}.`,
suggestion:
"Flatten part of the registry include tree or reduce nested include depth.",
})
return []
}
if (chain.includes(registryFile)) {
addDiagnostic(context, {
registryFile,
message: `Registry include cycle detected: ${formatIncludeCycle([
...chain,
registryFile,
])}.`,
suggestion: "Remove one include so the registry graph is acyclic.",
})
return []
}
const includedFrom = chain.at(-1) ?? registryFile
const existingSource = context.firstIncludedFrom.get(registryFile)
if (existingSource) {
addDiagnostic(context, {
registryFile,
message: `Registry file included more than once. First included from ${formatPath(
existingSource,
context.cwd
)}, then included from ${formatPath(includedFrom, context.cwd)}.`,
suggestion:
"Remove one include or move shared items into a single included registry.json.",
})
return []
}
context.registryFiles.add(registryFile)
context.firstIncludedFrom.set(registryFile, includedFrom)
const registryDir = path.dirname(registryFile)
const nextChain = [...chain, registryFile]
const includedItems: RegistryItem[] = []
for (const includePath of registry.include ?? []) {
const includedRegistryFile = resolveIncludePath(
includePath,
registryFile,
registryDir,
context
)
if (!includedRegistryFile) {
continue
}
const includedRegistry = await readRegistryFile(
includedRegistryFile,
context
)
if (!includedRegistry) {
continue
}
const items = await collectRegistryItems(
includedRegistryFile,
includedRegistry,
context,
nextChain
)
includedItems.push(...items)
}
const itemRegistryDir =
// Preserve legacy single-file registry behavior: item files resolve from cwd.
!context.usesInclude && registryFile === context.rootFile
? context.cwd
: registryDir
registry.items.forEach((item, itemIndex) => {
context.itemSourcesByItem.set(item, {
registryFile,
registryDir: itemRegistryDir,
itemIndex,
})
})
return [...includedItems, ...registry.items]
}
async function readRegistryFile(
registryFile: string,
context: RegistryValidationContext
) {
context.checkedRegistryFiles.add(registryFile)
let content: string
try {
content = await fs.readFile(registryFile, "utf-8")
} catch {
addDiagnostic(context, {
registryFile,
message: "Registry file was not found or could not be read.",
suggestion: "Check that the registry.json file exists and is readable.",
})
return null
}
let json: unknown
try {
json = JSON.parse(content)
} catch {
addDiagnostic(context, {
registryFile,
message: "Registry file contains invalid JSON.",
suggestion: "Fix the JSON syntax in the registry.json file.",
})
return null
}
return parseRegistryJson(json, registryFile, context)
}
function validateRootRegistry(
registry: RegistryChunk,
registryFile: string,
context: RegistryValidationContext
) {
if (!registry.name && !registry.hasName) {
addDiagnostic(context, {
registryFile,
message: 'Root registry.json must define "name".',
suggestion: 'Add a top-level "name" field to the root registry.json.',
})
}
if (!registry.homepage && !registry.hasHomepage) {
addDiagnostic(context, {
registryFile,
message: 'Root registry.json must define "homepage".',
suggestion: 'Add a top-level "homepage" field to the root registry.json.',
})
}
}
function resolveIncludePath(
includePath: string,
registryFile: string,
registryDir: string,
context: RegistryValidationContext
) {
if (isUrl(includePath)) {
addDiagnostic(context, {
registryFile,
includePath,
message: `Remote include "${includePath}" is not supported.`,
suggestion:
"Use a relative path to an explicit registry.json file in the same repository.",
})
return null
}
if (path.isAbsolute(includePath)) {
addDiagnostic(context, {
registryFile,
includePath,
message: `Include "${includePath}" must be relative.`,
suggestion: 'Use a path like "components/ui/registry.json".',
})
return null
}
if (hasParentTraversal(includePath)) {
addDiagnostic(context, {
registryFile,
includePath,
message: `Include "${includePath}" cannot use parent-directory traversal.`,
suggestion:
"Registry includes must descend from the including chunk. Move shared registries into the registry root and include them from there.",
})
return null
}
if (path.basename(includePath) !== "registry.json") {
addDiagnostic(context, {
registryFile,
includePath,
message: `Include "${includePath}" must explicitly reference a registry.json file.`,
suggestion: 'Use a path like "components/ui/registry.json".',
})
return null
}
const resolvedPath = path.resolve(registryDir, includePath)
if (!isPathInside(resolvedPath, context.cwd)) {
addDiagnostic(context, {
registryFile,
includePath,
message: `Include "${includePath}" must stay inside ${formatPath(
context.cwd,
context.cwd
)}.`,
suggestion: "Keep included registry.json files inside the registry root.",
})
return null
}
return resolvedPath
}
function validateDuplicateItems(
items: RegistryItem[],
context: RegistryValidationContext
) {
const seen = new Map<string, RegistryItem>()
for (const item of items) {
const existing = seen.get(item.name)
if (!existing) {
seen.set(item.name, item)
continue
}
const firstSource = context.itemSourcesByItem.get(existing)
const secondSource = context.itemSourcesByItem.get(item)
addDiagnostic(context, {
registryFile: secondSource?.registryFile ?? context.rootFile,
itemName: item.name,
itemIndex: secondSource?.itemIndex,
message: `Duplicate registry item name "${item.name}". First defined at ${formatItemSource(
firstSource,
context.cwd
)}.`,
suggestion:
"Rename one of these items so each name is unique across the resolved registry.",
})
}
}
async function validateRegistryItems(
items: RegistryItem[],
context: RegistryValidationContext
) {
const registryRootDir = getRegistryRootDir(context)
for (const item of items) {
const source = context.itemSourcesByItem.get(item)
const registryItem = {
...rewriteRegistryItemFilePaths(item, context, registryRootDir),
$schema: "https://ui.shadcn.com/schema/registry-item.json",
}
for (let index = 0; index < (item.files?.length ?? 0); index++) {
const file = item.files?.[index]
if (!file || !source) {
continue
}
const sourcePath = validateRegistryItemFilePath(
item,
file.path,
source,
context
)
if (!sourcePath) {
continue
}
try {
await fs.readFile(sourcePath, "utf-8")
} catch {
addDiagnostic(context, {
registryFile: source.registryFile,
itemName: item.name,
itemIndex: source.itemIndex,
filePath: file.path,
message: `File "${file.path}" was not found or could not be read.`,
suggestion:
"Make sure the file path is relative to the registry.json file that declares the item.",
})
}
}
const result = registryItemSchema.safeParse(registryItem)
if (!result.success) {
addZodDiagnostics(
result.error,
source?.registryFile ?? context.rootFile,
context,
{
itemName: item.name,
itemIndex: source?.itemIndex,
suggestion:
"Update the registry item so the built item matches the registry item schema.",
}
)
}
}
}
function validateRegistryItemFilePath(
item: RegistryItem,
filePath: string,
source: RegistryItemSource,
context: RegistryValidationContext
) {
if (isUrl(filePath)) {
addDiagnostic(context, {
registryFile: source.registryFile,
itemName: item.name,
itemIndex: source.itemIndex,
filePath,
message: `File path "${filePath}" cannot be remote.`,
suggestion:
"Use a local file path relative to the registry.json file that declares the item.",
})
return null
}
if (path.isAbsolute(filePath)) {
addDiagnostic(context, {
registryFile: source.registryFile,
itemName: item.name,
itemIndex: source.itemIndex,
filePath,
message: `File path "${filePath}" must be relative.`,
suggestion:
"Use a local file path relative to the registry.json file that declares the item.",
})
return null
}
if (hasParentTraversal(filePath)) {
addDiagnostic(context, {
registryFile: source.registryFile,
itemName: item.name,
itemIndex: source.itemIndex,
filePath,
message: `File path "${filePath}" cannot use parent-directory traversal.`,
suggestion: "Keep item files inside the registry chunk directory.",
})
return null
}
const sourcePath = path.resolve(source.registryDir, filePath)
if (!isPathInside(sourcePath, source.registryDir)) {
addDiagnostic(context, {
registryFile: source.registryFile,
itemName: item.name,
itemIndex: source.itemIndex,
filePath,
message: `File path "${filePath}" must stay inside the registry chunk directory.`,
suggestion:
"Move the file into the same registry chunk directory or update the registry item path.",
})
return null
}
return sourcePath
}
function rewriteRegistryItemFilePaths(
item: RegistryItem,
context: RegistryValidationContext,
rootDir: string
) {
const source = context.itemSourcesByItem.get(item)
return {
...item,
files: item.files?.map((file) => {
const sourcePath = path.resolve(
source?.registryDir ?? context.cwd,
file.path
)
return {
...file,
path: path.relative(rootDir, sourcePath).split(path.sep).join("/"),
}
}),
}
}
function parseRegistryJson(
json: unknown,
registryFile: string,
context: RegistryValidationContext
) {
const registryResult = registryObjectSchema.safeParse(json)
if (!registryResult.success) {
addZodDiagnostics(registryResult.error, registryFile, context, {
suggestion: "Update the registry.json file so it matches the schema.",
})
return null
}
const registry = registryResult.data
const chunk: RegistryChunk = {
$schema: getOptionalString(registry, "$schema", registryFile, context),
name: getOptionalString(registry, "name", registryFile, context),
homepage: getOptionalString(registry, "homepage", registryFile, context),
hasName: registry.name !== undefined,
hasHomepage: registry.homepage !== undefined,
items: [],
}
if (registry.include !== undefined) {
const result = registryIncludeSchema.safeParse(registry.include)
if (!result.success) {
addZodDiagnostics(result.error, registryFile, context, {
pathPrefix: ["include"],
suggestion: "Update include so it is an array of registry.json paths.",
})
} else {
chunk.include = result.data
}
}
if (registry.items !== undefined) {
const result = registryItemsSchema.safeParse(registry.items)
if (!result.success) {
addZodDiagnostics(result.error, registryFile, context, {
pathPrefix: ["items"],
suggestion: "Update items so it is an array of registry items.",
})
} else {
context.itemsChecked += result.data.length
chunk.items = parseRegistryItems(result.data, registryFile, context)
}
}
if (registry.items === undefined && registry.include === undefined) {
addDiagnostic(context, {
registryFile,
message: "Registry must define at least one of `items` or `include`.",
suggestion:
'Add an "items" array, an "include" array, or both to registry.json.',
})
}
return chunk
}
function parseRegistryItems(
items: unknown[],
registryFile: string,
context: RegistryValidationContext
) {
const registryItems: RegistryItem[] = []
items.forEach((item, itemIndex) => {
const result = registryItemSchema.safeParse(item)
if (!result.success) {
addZodDiagnostics(result.error, registryFile, context, {
itemName: getRawItemName(item),
itemIndex,
suggestion:
"Update the registry item so it matches the registry item schema.",
})
return
}
registryItems.push(result.data)
})
return registryItems
}
function getOptionalString(
registry: Record<string, unknown>,
key: string,
registryFile: string,
context: RegistryValidationContext
) {
const value = registry[key]
if (value === undefined) {
return undefined
}
if (typeof value === "string") {
return value
}
addDiagnostic(context, {
registryFile,
message: `${key}: Expected string, received ${typeof value}.`,
suggestion: `Update "${key}" so it is a string.`,
})
}
function getRawItemName(item: unknown) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return undefined
}
const name = (item as Record<string, unknown>).name
return typeof name === "string" ? name : undefined
}
function addZodDiagnostics(
error: z.ZodError,
registryFile: string,
context: RegistryValidationContext,
options: {
itemName?: string
itemIndex?: number
pathPrefix?: (string | number)[]
suggestion?: string
}
) {
for (const issue of error.errors) {
addDiagnostic(context, {
registryFile,
itemName: options.itemName,
itemIndex: options.itemIndex,
message: formatZodIssue(issue, options.pathPrefix),
suggestion: options.suggestion,
})
}
}
function addDiagnostic(
context: RegistryValidationContext,
diagnostic: RegistryValidationDiagnostic
) {
context.diagnostics.push(diagnostic)
}
function createValidationResult(
context: RegistryValidationContext,
items: RegistryItem[]
) {
return {
valid: context.diagnostics.length === 0,
cwd: context.cwd,
registryFiles: context.checkedRegistryFiles.size,
registryFilePaths: Array.from(context.checkedRegistryFiles),
items: context.itemsChecked,
diagnostics: context.diagnostics,
}
}
function getRegistryRootDir(context: RegistryValidationContext) {
return context.usesInclude ? path.dirname(context.rootFile) : context.cwd
}
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 chain
.map((file) => formatPath(file, path.dirname(chain[0])))
.join(" -> ")
}
function formatItemSource(source: RegistryItemSource | undefined, cwd: string) {
if (!source) {
return "unknown source"
}
return `${formatPath(source.registryFile, cwd)} items[${source.itemIndex}]`
}
function formatZodPath(issuePath: (string | number)[]) {
return issuePath.length ? issuePath.join(".") : "(root)"
}
function formatZodIssue(
issue: z.ZodIssue,
pathPrefix: (string | number)[] = []
) {
const path = [...pathPrefix, ...issue.path]
if (
issue.code === z.ZodIssueCode.invalid_union_discriminator &&
issue.path.at(-1) === "type"
) {
return `${formatZodPath(path)}: Invalid registry item type. Expected ${PUBLIC_REGISTRY_ITEM_TYPES.map(
(type) => `"${type}"`
).join(" | ")}.`
}
return `${formatZodPath(path)}: ${issue.message}`
}
function formatPath(filePath: string, cwd: string) {
const relativePath = path.relative(cwd, filePath)
if (
relativePath &&
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath)
) {
return relativePath.split(path.sep).join("/")
}
if (!relativePath) {
return "."
}
return filePath
}

3
pnpm-lock.yaml generated
View File

@@ -287,7 +287,7 @@ importers:
specifier: ^0.0.1
version: 0.0.1
shadcn:
specifier: 4.7.0
specifier: 4.8.0
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1
@@ -3618,6 +3618,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}