mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat(registry): add GitHub registry support (#10842)
* feat: add github scheme * fix * fix: validate and search * docs: update docs for GitHub registries * docs: add changelog * fix * chore: update announcement * docs(skills): update GitHub registry guidance * fix(registry): reject option-like GitHub refs * fix(registry): limit search registry discovery * fix(registry): bound GitHub validation concurrency * fix(registry): reject whitespace in GitHub refs * fix(registry): track URL dependency sources * test(registry): cover local dependency sources
This commit is contained in:
5
.changeset/tasty-cloths-film.md
Normal file
5
.changeset/tasty-cloths-film.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add support for GitHub registries
|
||||
@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||
export function Announcement() {
|
||||
return (
|
||||
<Badge asChild variant="secondary" className="bg-muted">
|
||||
<Link href="/docs/changelog">
|
||||
Introducing Rhea <ArrowRightIcon />
|
||||
<Link href="/docs/registry/github">
|
||||
Introducing GitHub Registries <ArrowRightIcon />
|
||||
</Link>
|
||||
</Badge>
|
||||
)
|
||||
|
||||
98
apps/v4/content/docs/changelog/2026-06-github-registries.mdx
Normal file
98
apps/v4/content/docs/changelog/2026-06-github-registries.mdx
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: June 2026 - GitHub Registries
|
||||
description: Turn any public GitHub repository into a shadcn registry.
|
||||
date: 2026-06-01
|
||||
---
|
||||
|
||||
**You can now turn any public GitHub repository into a registry.**
|
||||
|
||||
Add a `registry.json` file at the root of the repository, define the items you
|
||||
want to distribute, and users can install them directly from GitHub with the
|
||||
`shadcn` CLI.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add <username>/<repo>/<item>
|
||||
```
|
||||
|
||||
For example, to install the `project-conventions` item from the `acme/toolkit` repository:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
GitHub registries are source registries. You do not need to run `shadcn build`,
|
||||
publish generated item JSON files or set up a registry server. The CLI reads the
|
||||
root `registry.json`, resolves `include` entries, finds the requested item and
|
||||
installs the files declared by that item.
|
||||
|
||||
## Distribute anything
|
||||
|
||||
Registry items are not limited to components. A GitHub registry can distribute
|
||||
components, hooks, utilities, design tokens, feature kits, project conventions,
|
||||
agent instructions, testing setup, CI workflows, release workflows, templates,
|
||||
codemods, migration kits and other project files.
|
||||
|
||||
For example, a repository can expose a `project-conventions` item that installs
|
||||
shared docs, editor settings and agent instructions:
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "project-conventions",
|
||||
"type": "registry:item",
|
||||
"files": [
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/AGENTS.md"
|
||||
},
|
||||
{
|
||||
"path": ".editorconfig",
|
||||
"type": "registry:file",
|
||||
"target": "~/.editorconfig"
|
||||
},
|
||||
{
|
||||
"path": "docs/conventions.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/conventions.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
GitHub registry addresses work with the same commands as other registry
|
||||
addresses.
|
||||
|
||||
List items from a GitHub registry:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list acme/toolkit
|
||||
```
|
||||
|
||||
Search items:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search acme/toolkit --query conventions
|
||||
```
|
||||
|
||||
View an item:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
Install an item:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
See the [GitHub Registries](/docs/registry/github) docs for the full guide.
|
||||
@@ -76,6 +76,35 @@ To add a new color you need to add it to `cssVars` under `light` and `dark` keys
|
||||
|
||||
The CLI will update the project CSS file. Once updated, the new colors will be available to be used as utility classes: `bg-brand` and `text-brand-accent`.
|
||||
|
||||
### Why does `button` in `registryDependencies` not resolve to my GitHub repository?
|
||||
|
||||
Bare registry dependency names keep the existing shadcn behavior. `button`
|
||||
means the built-in shadcn `button` item.
|
||||
|
||||
For a dependency from a GitHub repository, use the full GitHub item address.
|
||||
|
||||
```json title="registry-item.json" showLineNumbers
|
||||
{
|
||||
"registryDependencies": ["acme/ui/button"]
|
||||
}
|
||||
```
|
||||
|
||||
### How do I pin a GitHub registry item?
|
||||
|
||||
Add `#ref` to the GitHub item address. The ref can be a branch, tag or full
|
||||
commit SHA.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/ui/button#v1.2.0
|
||||
```
|
||||
|
||||
For published registries, prefer tags or full commit SHAs.
|
||||
|
||||
### Can GitHub registry addresses use private repositories?
|
||||
|
||||
Not currently. GitHub registry addresses support public `github.com`
|
||||
repositories. For private registries, use a namespace with authenticated URLs.
|
||||
|
||||
### How do I add or override a Tailwind theme variable?
|
||||
|
||||
To add or override a theme variable you add it to `cssVars.theme` under the key you want to add or override.
|
||||
|
||||
@@ -3,15 +3,19 @@ title: Getting Started
|
||||
description: Learn how to get setup and run your own component registry.
|
||||
---
|
||||
|
||||
This guide will walk you through the process of setting up your own component registry. It assumes you already have a project with components and would like to turn it into a registry.
|
||||
This guide will walk you through the process of setting up your own registry. It assumes you already have a project with components, hooks, utilities or other files you would like to distribute.
|
||||
|
||||
**If you have an existing public GitHub repository, you can turn it into a
|
||||
registry by adding a `registry.json` file at the root.** See
|
||||
[GitHub Registries](/docs/registry/github) for details.
|
||||
|
||||
If you're starting a new registry project, you can use the [registry template](https://github.com/shadcn-ui/registry-template) as a starting point. We have already configured it for you.
|
||||
|
||||
## Requirements
|
||||
|
||||
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).
|
||||
You are free to design and publish your custom registry as you see fit. The only requirement is that your registry catalog and registry items must 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.
|
||||
Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP. It can also be a public GitHub repository with a `registry.json` file at the root.
|
||||
|
||||
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.
|
||||
|
||||
@@ -638,7 +642,7 @@ Here are some guidelines to follow when building components for a registry.
|
||||
- Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `default` as an example. It can be anything you want as long as it's nested under the `registry` directory.
|
||||
- For blocks, the following properties are required: `name`, `description`, `type` and `files`.
|
||||
- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
|
||||
- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is the name of the component in the registry eg. `input`, `button`, `card`, etc or a URL to a registry item eg. `http://localhost:3000/r/editor.json`.
|
||||
- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is an item address such as `button`, `@acme/input-form`, `acme/ui/button` or `http://localhost:3000/r/editor.json`.
|
||||
- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
|
||||
- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/default/hello-world/hello-world"`
|
||||
- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.
|
||||
|
||||
619
apps/v4/content/docs/registry/github.mdx
Normal file
619
apps/v4/content/docs/registry/github.mdx
Normal file
@@ -0,0 +1,619 @@
|
||||
---
|
||||
title: GitHub Registries
|
||||
description: Use a public GitHub repository as a registry.
|
||||
---
|
||||
|
||||
You can now turn **any public GitHub repository into a registry.**
|
||||
|
||||
Add a `registry.json` file to the root of the repo, describe the files you want
|
||||
to share, and users can install them with the `shadcn` CLI.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add <username>/<repo>/<item>
|
||||
```
|
||||
|
||||
You do not need to set up a registry server or publish generated JSON files. **The GitHub repository becomes the source registry.**
|
||||
|
||||
## Distribute Anything
|
||||
|
||||
Registry items are **not limited to components or React code.** They can include
|
||||
any files from your repository: source files, configuration, docs, templates,
|
||||
workflows, rules or project conventions.
|
||||
|
||||
<div className="not-prose my-6 overflow-hidden rounded-lg border text-sm">
|
||||
<div className="hidden grid-cols-[220px_1fr] border-b bg-muted/50 px-4 py-3 font-medium md:grid">
|
||||
<div>Use case</div>
|
||||
<div>Example files</div>
|
||||
</div>
|
||||
{[
|
||||
["Components", "components/date-picker.tsx", "components/data-table.tsx"],
|
||||
[
|
||||
"Helpers and utilities",
|
||||
"lib/format-date.ts",
|
||||
"lib/cn.ts",
|
||||
"hooks/use-copy.ts",
|
||||
],
|
||||
[
|
||||
"Design system packages",
|
||||
"tokens/colors.json",
|
||||
"styles/theme.css",
|
||||
"components/*",
|
||||
],
|
||||
[
|
||||
"Feature kits",
|
||||
"app/(auth)/*",
|
||||
"lib/auth.ts",
|
||||
"components/login-form.tsx",
|
||||
],
|
||||
["Agent workflows", "AGENTS.md", ".cursor/rules/*", ".claude/commands/*"],
|
||||
[
|
||||
"Project conventions",
|
||||
".editorconfig",
|
||||
"biome.json",
|
||||
"docs/conventions.md",
|
||||
],
|
||||
[
|
||||
"Codemods and migration kits",
|
||||
"codemods/*",
|
||||
"scripts/migrate.ts",
|
||||
"docs/migration.md",
|
||||
],
|
||||
["Testing setup", "vitest.config.ts", "test/setup.ts", "docs/testing.md"],
|
||||
[
|
||||
"CI and release workflows",
|
||||
".github/workflows/ci.yml",
|
||||
".github/workflows/release.yml",
|
||||
],
|
||||
[
|
||||
"Project automation",
|
||||
"scripts/release.ts",
|
||||
"scripts/checks.ts",
|
||||
"docs/automation.md",
|
||||
],
|
||||
[
|
||||
"Issue and pull request templates",
|
||||
".github/ISSUE_TEMPLATE/*",
|
||||
".github/pull_request_template.md",
|
||||
],
|
||||
["MCP configuration", ".mcp.json", ".cursor/mcp.json"],
|
||||
].map(([label, ...files]) => (
|
||||
<div
|
||||
className="grid gap-2 border-b px-4 py-3 last:border-b-0 md:grid-cols-[220px_1fr]"
|
||||
key={label}
|
||||
>
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{files.map((file) => (
|
||||
<code key={file}>{file}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
## When to use GitHub
|
||||
|
||||
Use a GitHub registry when:
|
||||
|
||||
- You already have reusable code in a public GitHub repository.
|
||||
- You want users to install directly from `owner/repo/item`.
|
||||
- You want to distribute config files, rules, docs, templates, utilities or
|
||||
any other files from the same repository.
|
||||
- You do not need private repo access or custom request authentication.
|
||||
|
||||
## Requirements
|
||||
|
||||
A GitHub registry must:
|
||||
|
||||
- Be a public `github.com` repository.
|
||||
- Have a `registry.json` file at the repository root.
|
||||
- Use valid `registry.json` and `registry-item.json` schemas.
|
||||
- Reference source files that exist in the repository.
|
||||
|
||||
Private repositories and GitHub Enterprise hosts are not currently supported by
|
||||
GitHub addresses. For private or authenticated registries, use a
|
||||
[namespace](/docs/registry/namespace) with
|
||||
[authentication](/docs/registry/authentication).
|
||||
|
||||
## Step 1: Add registry.json
|
||||
|
||||
Given an existing public repository:
|
||||
|
||||
```txt
|
||||
.
|
||||
├── ...
|
||||
├── .editorconfig
|
||||
├── AGENTS.md
|
||||
└── docs
|
||||
└── conventions.md
|
||||
```
|
||||
|
||||
Add `registry.json` at the root of the repository.
|
||||
|
||||
```txt
|
||||
.
|
||||
├── ...
|
||||
├── registry.json
|
||||
├── .editorconfig
|
||||
├── AGENTS.md
|
||||
└── docs
|
||||
└── conventions.md
|
||||
```
|
||||
|
||||
Define the item you want to distribute.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "project-conventions",
|
||||
"type": "registry:item",
|
||||
"title": "Project Conventions",
|
||||
"description": "Shared project conventions, editor settings and agent instructions.",
|
||||
"files": [
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/AGENTS.md"
|
||||
},
|
||||
{
|
||||
"path": ".editorconfig",
|
||||
"type": "registry:file",
|
||||
"target": "~/.editorconfig"
|
||||
},
|
||||
{
|
||||
"path": "docs/conventions.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/conventions.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Commit and push the file.
|
||||
|
||||
```bash
|
||||
git add registry.json
|
||||
```
|
||||
|
||||
```bash
|
||||
git commit -m "add registry"
|
||||
```
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
Users can now install the item from GitHub.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
## Step 2: Distribute any file
|
||||
|
||||
A registry item can install one file or many files. Use the `files` array to
|
||||
declare the files that belong together.
|
||||
|
||||
For example, a testing setup can install a Vitest config, a setup file and a
|
||||
short team guide.
|
||||
|
||||
```txt
|
||||
registry.json
|
||||
config
|
||||
└── vitest.config.ts
|
||||
docs
|
||||
└── testing.md
|
||||
test
|
||||
└── setup.ts
|
||||
```
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "vitest-setup",
|
||||
"type": "registry:item",
|
||||
"title": "Vitest Setup",
|
||||
"description": "A Vitest setup with project defaults and docs.",
|
||||
"files": [
|
||||
{
|
||||
"path": "config/vitest.config.ts",
|
||||
"type": "registry:file",
|
||||
"target": "~/vitest.config.ts"
|
||||
},
|
||||
{
|
||||
"path": "test/setup.ts",
|
||||
"type": "registry:file",
|
||||
"target": "~/test/setup.ts"
|
||||
},
|
||||
{
|
||||
"path": "docs/testing.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/testing.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Users install it the same way.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/vitest-setup
|
||||
```
|
||||
|
||||
Use `target` when a file should be written to a specific destination in the
|
||||
user's project.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "editorconfig",
|
||||
"type": "registry:file",
|
||||
"files": [
|
||||
{
|
||||
"path": "config/.editorconfig",
|
||||
"type": "registry:file",
|
||||
"target": "~/.editorconfig"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/editorconfig
|
||||
```
|
||||
|
||||
## Step 3: Validate the registry
|
||||
|
||||
Before sharing the registry, validate it from the CLI.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest registry validate acme/toolkit
|
||||
```
|
||||
|
||||
The command reads the root `registry.json`, resolves includes, validates the
|
||||
registry items, and checks that referenced files exist.
|
||||
|
||||
You can also validate a branch, tag or commit SHA.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest registry validate acme/toolkit#v1.0.0
|
||||
```
|
||||
|
||||
## Step 4: List and search items
|
||||
|
||||
Use `list` to see every item in the repository registry.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list acme/toolkit
|
||||
```
|
||||
|
||||
Use `search` to filter the catalog.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search acme/toolkit --query conventions
|
||||
```
|
||||
|
||||
Use `view` to inspect one item payload.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
## Organize with include
|
||||
|
||||
For larger repositories, keep item definitions close to the source files they
|
||||
describe.
|
||||
|
||||
```txt
|
||||
registry.json
|
||||
config
|
||||
├── prettier.config.mjs
|
||||
└── registry.json
|
||||
rules
|
||||
├── agent.md
|
||||
└── registry.json
|
||||
```
|
||||
|
||||
The root `registry.json` can include the nested registry files.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"include": ["config/registry.json", "rules/registry.json"]
|
||||
}
|
||||
```
|
||||
|
||||
The included registry file declares items for that directory.
|
||||
|
||||
```json title="rules/registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"items": [
|
||||
{
|
||||
"name": "agent-rules",
|
||||
"type": "registry:file",
|
||||
"files": [
|
||||
{
|
||||
"path": "agent.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/AGENTS.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When using `include`, file paths are relative to the `registry.json` file that
|
||||
declares the item.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
## Registry dependencies
|
||||
|
||||
Use `registryDependencies` when one registry item depends on another registry
|
||||
item.
|
||||
|
||||
### Same repository dependencies
|
||||
|
||||
For dependencies in the same GitHub repository, use the full GitHub item
|
||||
address.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "project-setup",
|
||||
"type": "registry:item",
|
||||
"registryDependencies": [
|
||||
"acme/toolkit/agent-rules",
|
||||
"acme/toolkit/prettier-config",
|
||||
"acme/toolkit/tsconfig"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/project-setup.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/project-setup.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A docs item can depend on a template item from the same repository.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "contributing-guide",
|
||||
"type": "registry:item",
|
||||
"registryDependencies": ["acme/toolkit/readme-template"],
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/contributing.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/contributing.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A CI setup can depend on the same formatting and testing defaults that users can
|
||||
install separately.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "ci-setup",
|
||||
"type": "registry:item",
|
||||
"registryDependencies": [
|
||||
"acme/toolkit/prettier-config",
|
||||
"acme/toolkit/vitest-setup"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": ".github/workflows/ci.yml",
|
||||
"type": "registry:file",
|
||||
"target": "~/.github/workflows/ci.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### External registry dependencies
|
||||
|
||||
Items can also depend on external registries. Use the full item address for the
|
||||
registry that owns the dependency.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "workspace-setup",
|
||||
"type": "registry:item",
|
||||
"registryDependencies": [
|
||||
"@acme/tsconfig",
|
||||
"contoso/devtools/prettier-config"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/workspace.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/workspace.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency refs
|
||||
|
||||
Refs are not inherited across dependencies. If a dependency should be pinned,
|
||||
include its own ref.
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme-toolkit",
|
||||
"homepage": "https://github.com/acme/toolkit",
|
||||
"items": [
|
||||
{
|
||||
"name": "project-setup",
|
||||
"type": "registry:item",
|
||||
"registryDependencies": [
|
||||
"acme/toolkit/agent-rules#v1.0.0",
|
||||
"acme/toolkit/tsconfig#c0ffee254729296a45d6691db565cf707a3fef5d"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/project-setup.md",
|
||||
"type": "registry:file",
|
||||
"target": "~/docs/project-setup.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
List every item in a GitHub registry.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list acme/toolkit
|
||||
```
|
||||
|
||||
Search a GitHub registry.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search acme/toolkit -q conventions
|
||||
```
|
||||
|
||||
Validate a GitHub registry.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest registry validate acme/toolkit
|
||||
```
|
||||
|
||||
Install an item from a GitHub registry.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
View an item from a GitHub registry.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view acme/toolkit/project-conventions
|
||||
```
|
||||
|
||||
Install an item whose registry item name contains `/`.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/rules/agent
|
||||
```
|
||||
|
||||
<Callout>
|
||||
For GitHub item addresses, the first two path segments are the GitHub owner
|
||||
and repository. Any remaining segments are the registry item name, not a file
|
||||
path. An address ending in `.json` is treated as a file path.
|
||||
</Callout>
|
||||
|
||||
Install from a tag.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions#v1.0.0
|
||||
```
|
||||
|
||||
Install from a full commit SHA.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions#c0ffee254729296a45d6691db565cf707a3fef5d
|
||||
```
|
||||
|
||||
## Refs
|
||||
|
||||
Use `#ref` to install from a branch, tag or commit SHA.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions#main
|
||||
```
|
||||
|
||||
Refs may contain slashes.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/toolkit/project-conventions#feature/conventions
|
||||
```
|
||||
|
||||
If no ref is provided, the CLI uses the repository default branch.
|
||||
|
||||
The CLI uses Git to resolve branches, tags and short refs into a commit SHA
|
||||
before reading files. Full 40-character commit SHAs are used directly and do not
|
||||
require Git.
|
||||
|
||||
## Review before installing
|
||||
|
||||
GitHub registry items install code and project files from public repositories.
|
||||
Treat a GitHub item address like any other third-party code dependency.
|
||||
|
||||
Before installing from a source you do not control:
|
||||
|
||||
- Review the repository and the root `registry.json`.
|
||||
- Review the item definition, especially `files`, `target`, `dependencies`,
|
||||
`devDependencies`, `registryDependencies` and `envVars`.
|
||||
- Check any external registry dependencies. They can install files from other
|
||||
registries.
|
||||
- Prefer pinned refs for published install commands. A full 40-character commit
|
||||
SHA is the most reproducible option.
|
||||
- Use `shadcn view acme/toolkit/project-conventions` to inspect the resolved
|
||||
item payload before installing.
|
||||
- Pipe `shadcn view` output to your agent or review tool if you want help
|
||||
checking the item.
|
||||
- Use `shadcn add acme/toolkit/project-conventions --dry-run` to preview an
|
||||
install without writing files.
|
||||
- Use `--diff` or `--view` with `shadcn add` to inspect file changes or file
|
||||
contents before applying them.
|
||||
@@ -33,13 +33,33 @@ You can use the `shadcn` CLI to run your own code registry. Running your own reg
|
||||
Ready to create your own registry? In the next section, we'll walk you through setting up your own custom registry step-by-step, from creating your first component to publishing it for others to use.
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<LinkedCard href="/docs/registry/getting-started" className="items-start text-sm md:p-6">
|
||||
<LinkedCard
|
||||
href="/docs/registry/getting-started"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Getting Started</div>
|
||||
<div className="text-muted-foreground">
|
||||
Set up and build your own registry
|
||||
</div>
|
||||
</LinkedCard>
|
||||
|
||||
<LinkedCard href="/docs/registry/github" className="items-start text-sm md:p-6">
|
||||
<div className="font-medium">GitHub</div>
|
||||
<div className="text-muted-foreground">
|
||||
Turn a GitHub repository into a registry
|
||||
</div>
|
||||
</LinkedCard>
|
||||
|
||||
<LinkedCard
|
||||
href="/docs/registry/namespace"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Namespaces</div>
|
||||
<div className="text-muted-foreground">
|
||||
Configure registries with namespaces
|
||||
</div>
|
||||
</LinkedCard>
|
||||
|
||||
<LinkedCard
|
||||
href="/docs/registry/authentication"
|
||||
className="items-start text-sm md:p-6"
|
||||
@@ -49,24 +69,15 @@ Ready to create your own registry? In the next section, we'll walk you through s
|
||||
Secure your registry with authentication
|
||||
</div>
|
||||
</LinkedCard>
|
||||
<LinkedCard
|
||||
href="/docs/registry/namespace"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Namespaces</div>
|
||||
<div className="text-muted-foreground">
|
||||
Configure registries with namespaces
|
||||
</div>
|
||||
</LinkedCard>
|
||||
|
||||
<LinkedCard
|
||||
href="/docs/registry/examples"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Examples</div>
|
||||
<div className="text-muted-foreground">
|
||||
Registry item examples and configurations
|
||||
</div>
|
||||
<div className="text-muted-foreground">Browse example registry items</div>
|
||||
</LinkedCard>
|
||||
|
||||
<LinkedCard
|
||||
href="/docs/registry/registry-json"
|
||||
className="items-start text-sm md:p-6"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"pages": [
|
||||
"index",
|
||||
"getting-started",
|
||||
"github",
|
||||
"registry-index",
|
||||
"examples",
|
||||
"namespace",
|
||||
|
||||
@@ -156,6 +156,28 @@ The pattern for referencing resources is: `@namespace/resource-name`
|
||||
|
||||
---
|
||||
|
||||
## GitHub and Namespaces
|
||||
|
||||
GitHub registry addresses and namespaces solve different problems.
|
||||
|
||||
Use a GitHub address when the registry is a public GitHub repository and you
|
||||
want users to install without configuring `components.json`.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add acme/ui/button
|
||||
```
|
||||
|
||||
Use a namespace when you want a stable alias, custom hosting, authentication,
|
||||
request headers, query parameters or private registry support.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @acme/button
|
||||
```
|
||||
|
||||
See the [GitHub registry](/docs/registry/github) docs for more information.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Namespaced registries are configured in your `components.json` file under the `registries` field.
|
||||
|
||||
@@ -9,6 +9,10 @@ When you run `shadcn add` or `shadcn search`, the CLI will automatically check t
|
||||
|
||||
You can see the full list at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json).
|
||||
|
||||
You do not need to submit a public GitHub registry to the registry directory to
|
||||
use it with `owner/repo/item` addresses. The registry directory is for
|
||||
namespaces such as `@acme`.
|
||||
|
||||
## Adding a Registry
|
||||
|
||||
1. Add your registry to [`apps/v4/registry/directory.json`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/directory.json)
|
||||
|
||||
@@ -161,23 +161,34 @@ Use `@version` to specify the version of the package.
|
||||
|
||||
### registryDependencies
|
||||
|
||||
Used for registry dependencies. Can be names, namespaced or URLs.
|
||||
Used for registry dependencies. Each entry is an item address.
|
||||
|
||||
- For `shadcn/ui` registry items such as `button`, `input`, `select`, etc use the name eg. `['button', 'input', 'select']`.
|
||||
- For namespaced registry items such as `@acme` use the name eg. `['@acme/input-form']`.
|
||||
- For namespaced registry items, use `@namespace/item-name` eg. `['@acme/input-form']`.
|
||||
- For GitHub registry items, use `owner/repo/item-name` eg. `['acme/ui/button']`. For published registries, prefer a tag or full commit SHA eg. `['acme/ui/button#v1.2.0']`.
|
||||
- For custom registry items use the URL of the registry item eg. `['https://example.com/r/hello-world.json']`.
|
||||
- For local registry item files use a file path eg. `['./hello-world.json']`.
|
||||
|
||||
```json title="registry-item.json" showLineNumbers
|
||||
{
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"@acme/input-form",
|
||||
"https://example.com/r/editor.json"
|
||||
"acme/ui/button#v1.2.0",
|
||||
"https://example.com/r/editor.json",
|
||||
"./editor.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note: The CLI will automatically resolve remote registry dependencies.
|
||||
Note: Bare names keep their existing behavior. `button` means the built-in
|
||||
shadcn `button` item, not an item from the same GitHub repository. For
|
||||
same-repository GitHub dependencies, use the full GitHub item address.
|
||||
|
||||
Refs are not inherited across dependencies. If a GitHub dependency should be
|
||||
reproducible, pin that dependency to its own tag or full commit SHA.
|
||||
|
||||
See the [GitHub registry](/docs/registry/github) docs for more information.
|
||||
|
||||
### files
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ using `include`.
|
||||
}
|
||||
```
|
||||
|
||||
Public GitHub repositories use the same source registry format. The CLI reads
|
||||
the root `registry.json`, resolves `include`, and installs files from the
|
||||
repository. See the [GitHub registry](/docs/registry/github) docs for more
|
||||
information.
|
||||
|
||||
## Definitions
|
||||
|
||||
You can see the JSON Schema for `registry.json` [here](https://ui.shadcn.com/schema/registry.json).
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export const PAGES_NEW = [
|
||||
"/create",
|
||||
"/docs/registry",
|
||||
"/docs/registry/getting-started",
|
||||
"/docs/changelog",
|
||||
]
|
||||
export const PAGES_NEW = ["/create", "/docs/registry/github", "/docs/changelog"]
|
||||
|
||||
export const PAGES_UPDATED = ["/docs/components/button"]
|
||||
|
||||
@@ -46,7 +46,7 @@ export const addOptionsSchema = z.object({
|
||||
export const add = new Command()
|
||||
.name("add")
|
||||
.description("add a component to your project")
|
||||
.argument("[components...]", "names, url or local path to component")
|
||||
.argument("[components...]", "item addresses to add")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option("-o, --overwrite", "overwrite existing files.", false)
|
||||
.option(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import * as path from "path"
|
||||
import { validateGitHubRegistrySource } from "@/src/registry/github"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
@@ -37,6 +38,17 @@ vi.mock("@/src/utils/spinner", () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/github", () => ({
|
||||
validateGitHubRegistrySource: vi.fn(async () => ({
|
||||
valid: true,
|
||||
cwd: "acme/ui#HEAD",
|
||||
registryFiles: 1,
|
||||
registryFilePaths: ["acme/ui#HEAD/registry.json"],
|
||||
items: 2,
|
||||
diagnostics: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
describe("registry validate command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -120,6 +132,47 @@ describe("registry validate command", () => {
|
||||
])
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it("validates a GitHub source registry", async () => {
|
||||
await validate.parseAsync(["acme/ui"], {
|
||||
from: "user",
|
||||
})
|
||||
|
||||
expect(validateGitHubRegistrySource).toHaveBeenCalledWith({
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
ref: undefined,
|
||||
})
|
||||
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 2 items.")
|
||||
expect(summarySpinner.succeed).toHaveBeenCalled()
|
||||
expect(logger.log).toHaveBeenCalledWith(" - registry.json")
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not treat an existing local path as a GitHub source registry", async () => {
|
||||
const cwd = await createFixture({
|
||||
"acme/ui": JSON.stringify({
|
||||
name: "example",
|
||||
homepage: "https://example.com",
|
||||
items: [],
|
||||
}),
|
||||
})
|
||||
|
||||
await validate.parseAsync(["acme/ui", "--cwd", cwd], {
|
||||
from: "user",
|
||||
})
|
||||
|
||||
expect(validateGitHubRegistrySource).not.toHaveBeenCalled()
|
||||
const validationSpinner = vi.mocked(spinner).mock.results[0].value
|
||||
expect(validationSpinner.fail).toHaveBeenCalledWith(
|
||||
"Registry validation failed."
|
||||
)
|
||||
expect(logger.log).toHaveBeenCalledWith(" - acme/ui")
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
async function createFixture(files: Record<string, string>) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { resolveGitHubRegistrySource } from "@/src/registry/address"
|
||||
import { validateGitHubRegistrySource } from "@/src/registry/github"
|
||||
import { validateRegistry } from "@/src/registry/validate"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
@@ -12,10 +15,16 @@ const validateOptionsSchema = z.object({
|
||||
registryFile: z.string(),
|
||||
})
|
||||
|
||||
type RegistryValidationReport = Awaited<ReturnType<typeof validateRegistry>>
|
||||
|
||||
export const validate = new Command()
|
||||
.name("validate")
|
||||
.description("validate a shadcn registry")
|
||||
.argument("[registry]", "path to registry.json file", "./registry.json")
|
||||
.argument(
|
||||
"[registry]",
|
||||
"registry address to validate. Supports registry.json paths and GitHub sources.",
|
||||
"./registry.json"
|
||||
)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
@@ -29,8 +38,14 @@ export const validate = new Command()
|
||||
cwd: path.resolve(opts.cwd),
|
||||
registryFile,
|
||||
})
|
||||
const githubSource = await resolveGitHubRegistryValidationSource(
|
||||
registryFile,
|
||||
options.cwd
|
||||
)
|
||||
validationSpinner = spinner("Validating registry.").start()
|
||||
const report = await validateRegistry(options)
|
||||
const report = githubSource
|
||||
? await validateGitHubRegistrySource(githubSource)
|
||||
: await validateRegistry(options)
|
||||
|
||||
printRegistryValidationReport(report, validationSpinner)
|
||||
|
||||
@@ -45,7 +60,7 @@ export const validate = new Command()
|
||||
})
|
||||
|
||||
function printRegistryValidationReport(
|
||||
report: Awaited<ReturnType<typeof validateRegistry>>,
|
||||
report: RegistryValidationReport,
|
||||
validationSpinner: ReturnType<typeof spinner>
|
||||
) {
|
||||
if (report.valid) {
|
||||
@@ -75,7 +90,7 @@ function printRegistryValidationReport(
|
||||
}
|
||||
|
||||
function printRegistryValidationStats(
|
||||
report: Awaited<ReturnType<typeof validateRegistry>>,
|
||||
report: RegistryValidationReport,
|
||||
options: {
|
||||
success?: boolean
|
||||
} = {}
|
||||
@@ -97,9 +112,7 @@ function printRegistryValidationStats(
|
||||
}
|
||||
}
|
||||
|
||||
function groupDiagnostics(
|
||||
report: Awaited<ReturnType<typeof validateRegistry>>
|
||||
) {
|
||||
function groupDiagnostics(report: RegistryValidationReport) {
|
||||
const groups = new Map<string, typeof report.diagnostics>()
|
||||
|
||||
for (const diagnostic of report.diagnostics) {
|
||||
@@ -112,9 +125,7 @@ function groupDiagnostics(
|
||||
}
|
||||
|
||||
function formatDiagnostic(
|
||||
diagnostic: Awaited<
|
||||
ReturnType<typeof validateRegistry>
|
||||
>["diagnostics"][number]
|
||||
diagnostic: RegistryValidationReport["diagnostics"][number]
|
||||
) {
|
||||
const context = []
|
||||
|
||||
@@ -166,3 +177,35 @@ function printSuccess(message: string) {
|
||||
function formatCount(count: number, singular: string, plural: string) {
|
||||
return `${count} ${count === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
async function resolveGitHubRegistryValidationSource(
|
||||
registry: string,
|
||||
cwd: string
|
||||
) {
|
||||
if (isLocalRegistryPath(registry)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (await fileExists(path.resolve(cwd, registry))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveGitHubRegistrySource(registry)
|
||||
}
|
||||
|
||||
function isLocalRegistryPath(registry: string) {
|
||||
return (
|
||||
registry.startsWith(".") ||
|
||||
path.isAbsolute(registry) ||
|
||||
registry.endsWith(".json")
|
||||
)
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
121
packages/shadcn/src/commands/search.test.ts
Normal file
121
packages/shadcn/src/commands/search.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { search } from "./search"
|
||||
|
||||
const baseConfig = {
|
||||
$schema: "",
|
||||
style: "new-york",
|
||||
rsc: false,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "",
|
||||
baseColor: "neutral",
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
ui: "@/components/ui",
|
||||
hooks: "@/hooks",
|
||||
lib: "@/lib",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
registries: {},
|
||||
resolvedPaths: {
|
||||
cwd: "/tmp/test-project",
|
||||
tailwindConfig: "",
|
||||
tailwindCss: "",
|
||||
utils: "",
|
||||
components: "",
|
||||
lib: "",
|
||||
hooks: "",
|
||||
ui: "",
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock("fs-extra", () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => false),
|
||||
readJson: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/env-loader", () => ({
|
||||
loadEnvFiles: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-config", () => ({
|
||||
createConfig: vi.fn(() => baseConfig),
|
||||
getConfig: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/registries", () => ({
|
||||
ensureRegistriesInConfig: vi.fn(() => ({
|
||||
config: baseConfig,
|
||||
newRegistries: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/validator", () => ({
|
||||
validateRegistryConfigForItems: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/search", () => ({
|
||||
searchRegistries: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/context", () => ({
|
||||
clearRegistryContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn((error) => {
|
||||
throw error
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("search command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("only discovers namespace registries for search inputs", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
await expect(
|
||||
search.parseAsync(
|
||||
[
|
||||
"@acme",
|
||||
"acme/ui",
|
||||
"https://example.com/registry.json",
|
||||
"--cwd",
|
||||
"/tmp/test-project",
|
||||
],
|
||||
{
|
||||
from: "user",
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("process.exit:0")
|
||||
|
||||
expect(ensureRegistriesInConfig).toHaveBeenCalledWith(
|
||||
["@acme/registry"],
|
||||
expect.any(Object),
|
||||
{
|
||||
silent: true,
|
||||
writeFile: false,
|
||||
}
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
function mockProcessExit() {
|
||||
return vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit:${code}`)
|
||||
})
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export const search = new Command()
|
||||
.description("search items from registries")
|
||||
.argument(
|
||||
"<registries...>",
|
||||
"the registry names or urls to search items from. Names must be prefixed with @."
|
||||
"the registry addresses to search. Supports namespaces, GitHub sources and URLs."
|
||||
)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
@@ -87,7 +87,9 @@ export const search = new Command()
|
||||
|
||||
const { config: updatedConfig, newRegistries } =
|
||||
await ensureRegistriesInConfig(
|
||||
registries.map((registry) => `${registry}/registry`),
|
||||
registries
|
||||
.filter((registry) => registry.startsWith("@"))
|
||||
.map((registry) => `${registry}/registry`),
|
||||
config,
|
||||
{
|
||||
silent: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ const viewOptionsSchema = z.object({
|
||||
export const view = new Command()
|
||||
.name("view")
|
||||
.description("view items from the registry")
|
||||
.argument("<items...>", "the item names or URLs to view")
|
||||
.argument("<items...>", "item addresses to view")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
|
||||
180
packages/shadcn/src/registry/address.test.ts
Normal file
180
packages/shadcn/src/registry/address.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveGitHubRegistrySource, resolveItemAddress } from "./address"
|
||||
import { RegistryValidationError } from "./errors"
|
||||
|
||||
describe("resolveItemAddress", () => {
|
||||
it.each([
|
||||
["button", { scheme: "shadcn", item: "button" }],
|
||||
["calendar", { scheme: "shadcn", item: "calendar" }],
|
||||
])("resolves shadcn item address %s", (input, expected) => {
|
||||
expect(resolveItemAddress(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
"@acme/button",
|
||||
{ scheme: "namespace", namespace: "@acme", item: "button" },
|
||||
],
|
||||
[
|
||||
"@acme/ui/button",
|
||||
{ scheme: "namespace", namespace: "@acme", item: "ui/button" },
|
||||
],
|
||||
])("resolves namespace item address %s", (input, expected) => {
|
||||
expect(resolveItemAddress(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
"https://example.com/r/button.json",
|
||||
{ scheme: "url", url: "https://example.com/r/button.json" },
|
||||
],
|
||||
[
|
||||
"https://example.com/r/button.json#fragment",
|
||||
{ scheme: "url", url: "https://example.com/r/button.json#fragment" },
|
||||
],
|
||||
])("resolves url item address %s", (input, expected) => {
|
||||
expect(resolveItemAddress(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["./button.json", { scheme: "file", path: "./button.json" }],
|
||||
[
|
||||
"../registry/button.json",
|
||||
{ scheme: "file", path: "../registry/button.json" },
|
||||
],
|
||||
[
|
||||
"/Users/me/registry/button.json",
|
||||
{ scheme: "file", path: "/Users/me/registry/button.json" },
|
||||
],
|
||||
])("resolves file item address %s", (input, expected) => {
|
||||
expect(resolveItemAddress(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
"acme/ui/button",
|
||||
{
|
||||
scheme: "github",
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
item: "button",
|
||||
},
|
||||
],
|
||||
[
|
||||
"acme/ui/forms/login",
|
||||
{
|
||||
scheme: "github",
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
item: "forms/login",
|
||||
},
|
||||
],
|
||||
[
|
||||
"acme/ui/button#v1.2.0",
|
||||
{
|
||||
scheme: "github",
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
item: "button",
|
||||
ref: "v1.2.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
"acme/ui/button#feature/login-form",
|
||||
{
|
||||
scheme: "github",
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
item: "button",
|
||||
ref: "feature/login-form",
|
||||
},
|
||||
],
|
||||
])("resolves GitHub item address %s", (input, expected) => {
|
||||
expect(resolveItemAddress(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["foo/bar", { scheme: "shadcn", item: "foo/bar" }],
|
||||
["-owner/repo/button", { scheme: "shadcn", item: "-owner/repo/button" }],
|
||||
["owner-/repo/button", { scheme: "shadcn", item: "owner-/repo/button" }],
|
||||
["owner/./button", { scheme: "shadcn", item: "owner/./button" }],
|
||||
["owner/../button", { scheme: "shadcn", item: "owner/../button" }],
|
||||
[
|
||||
"owner/repo with space/button",
|
||||
{ scheme: "shadcn", item: "owner/repo with space/button" },
|
||||
],
|
||||
])(
|
||||
"does not classify invalid GitHub addresses as GitHub: %s",
|
||||
(input, expected) => {
|
||||
expect(resolveItemAddress(input)).toEqual(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it("keeps .json addresses classified as file paths", () => {
|
||||
expect(resolveItemAddress("owner/repo/data/schema.json")).toEqual({
|
||||
scheme: "file",
|
||||
path: "owner/repo/data/schema.json",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects empty GitHub refs", () => {
|
||||
expect(() => resolveItemAddress("acme/ui/button#")).toThrow(
|
||||
RegistryValidationError
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects GitHub refs with control characters", () => {
|
||||
expect(() => resolveItemAddress("acme/ui/button#bad\nref")).toThrow(
|
||||
RegistryValidationError
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects GitHub refs with whitespace", () => {
|
||||
expect(() => resolveItemAddress("acme/ui/button#my tag")).toThrow(
|
||||
RegistryValidationError
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects GitHub refs that look like git options", () => {
|
||||
expect(() =>
|
||||
resolveItemAddress("acme/ui/button#--upload-pack=/bin/false")
|
||||
).toThrow(RegistryValidationError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveGitHubRegistrySource", () => {
|
||||
it.each([
|
||||
["acme/ui", { owner: "acme", repo: "ui" }],
|
||||
["acme/ui#v1.0.0", { owner: "acme", repo: "ui", ref: "v1.0.0" }],
|
||||
[
|
||||
"acme/ui#feature/forms",
|
||||
{ owner: "acme", repo: "ui", ref: "feature/forms" },
|
||||
],
|
||||
])("resolves GitHub registry source %s", (input, expected) => {
|
||||
expect(resolveGitHubRegistrySource(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"acme",
|
||||
"acme/ui/button",
|
||||
"@acme/ui",
|
||||
"https://example.com",
|
||||
"owner/.",
|
||||
"owner/..",
|
||||
])("does not resolve non-GitHub registry source %s", (input) => {
|
||||
expect(resolveGitHubRegistrySource(input)).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects refs that look like git options", () => {
|
||||
expect(() =>
|
||||
resolveGitHubRegistrySource("acme/ui#--upload-pack=/bin/false")
|
||||
).toThrow(RegistryValidationError)
|
||||
})
|
||||
|
||||
it("rejects refs with whitespace", () => {
|
||||
expect(() => resolveGitHubRegistrySource("acme/ui#my tag")).toThrow(
|
||||
RegistryValidationError
|
||||
)
|
||||
})
|
||||
})
|
||||
182
packages/shadcn/src/registry/address.ts
Normal file
182
packages/shadcn/src/registry/address.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { RegistryValidationError } from "@/src/registry/errors"
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import { isLocalFile, isUrl } from "@/src/registry/utils"
|
||||
|
||||
const GITHUB_OWNER_PATTERN =
|
||||
/^(?!.*--)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/
|
||||
const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9._-]+$/
|
||||
const INVALID_GITHUB_REPO_NAMES = new Set([".", ".."])
|
||||
const CONTROL_CHARACTER_PATTERN = /[\x00-\x1F\x7F]/
|
||||
const WHITESPACE_PATTERN = /\s/
|
||||
const GITHUB_REF_OPTION_PATTERN = /^-/
|
||||
|
||||
export type ResolvedItemAddress =
|
||||
| {
|
||||
scheme: "shadcn"
|
||||
item: string
|
||||
}
|
||||
| {
|
||||
scheme: "namespace"
|
||||
namespace: string
|
||||
item: string
|
||||
}
|
||||
| {
|
||||
scheme: "url"
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
scheme: "file"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
scheme: "github"
|
||||
owner: string
|
||||
repo: string
|
||||
item: string
|
||||
ref?: string
|
||||
}
|
||||
|
||||
export type ResolvedGitHubRegistrySource = {
|
||||
owner: string
|
||||
repo: string
|
||||
ref?: string
|
||||
}
|
||||
|
||||
export function resolveItemAddress(address: string) {
|
||||
if (isUrl(address)) {
|
||||
return {
|
||||
scheme: "url",
|
||||
url: address,
|
||||
} satisfies ResolvedItemAddress
|
||||
}
|
||||
|
||||
if (isLocalFile(address)) {
|
||||
return {
|
||||
scheme: "file",
|
||||
path: address,
|
||||
} satisfies ResolvedItemAddress
|
||||
}
|
||||
|
||||
const { registry, item } = parseRegistryAndItemFromString(address)
|
||||
if (registry) {
|
||||
return {
|
||||
scheme: "namespace",
|
||||
namespace: registry,
|
||||
item,
|
||||
} satisfies ResolvedItemAddress
|
||||
}
|
||||
|
||||
const githubAddress = resolveGitHubItemAddress(address)
|
||||
if (githubAddress) {
|
||||
return githubAddress
|
||||
}
|
||||
|
||||
return {
|
||||
scheme: "shadcn",
|
||||
item: address,
|
||||
} satisfies ResolvedItemAddress
|
||||
}
|
||||
|
||||
export function isGitHubItemAddress(address: string) {
|
||||
return resolveItemAddress(address).scheme === "github"
|
||||
}
|
||||
|
||||
export function resolveGitHubRegistrySource(source: string) {
|
||||
const hashIndex = source.indexOf("#")
|
||||
const path = hashIndex === -1 ? source : source.slice(0, hashIndex)
|
||||
const ref = hashIndex === -1 ? undefined : source.slice(hashIndex + 1)
|
||||
const parts = path.split("/")
|
||||
|
||||
if (parts.length !== 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [owner, repo] = parts
|
||||
if (!isGitHubOwner(owner) || !isGitHubRepo(repo)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ref !== undefined && !isValidGitHubRef(ref)) {
|
||||
throw new RegistryValidationError(
|
||||
`Invalid GitHub ref in registry source "${source}".`,
|
||||
{
|
||||
context: {
|
||||
source,
|
||||
ref,
|
||||
},
|
||||
suggestion:
|
||||
"Use a non-empty branch, tag, or commit SHA without whitespace, control characters or leading dashes.",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
} satisfies ResolvedGitHubRegistrySource
|
||||
}
|
||||
|
||||
export function isGitHubRegistrySource(source: string) {
|
||||
return resolveGitHubRegistrySource(source) !== null
|
||||
}
|
||||
|
||||
function resolveGitHubItemAddress(address: string) {
|
||||
const hashIndex = address.indexOf("#")
|
||||
const source = hashIndex === -1 ? address : address.slice(0, hashIndex)
|
||||
const ref = hashIndex === -1 ? undefined : address.slice(hashIndex + 1)
|
||||
const parts = source.split("/")
|
||||
|
||||
if (parts.length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [owner, repo, ...itemParts] = parts
|
||||
if (!isGitHubOwner(owner) || !isGitHubRepo(repo)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const item = itemParts.join("/")
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ref !== undefined && !isValidGitHubRef(ref)) {
|
||||
throw new RegistryValidationError(
|
||||
`Invalid GitHub ref in item address "${address}".`,
|
||||
{
|
||||
context: {
|
||||
address,
|
||||
ref,
|
||||
},
|
||||
suggestion:
|
||||
"Use a non-empty branch, tag, or commit SHA without whitespace, control characters or leading dashes.",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
scheme: "github",
|
||||
owner,
|
||||
repo,
|
||||
item,
|
||||
ref,
|
||||
} satisfies Extract<ResolvedItemAddress, { scheme: "github" }>
|
||||
}
|
||||
|
||||
function isGitHubOwner(owner: string) {
|
||||
return GITHUB_OWNER_PATTERN.test(owner)
|
||||
}
|
||||
|
||||
function isGitHubRepo(repo: string) {
|
||||
return GITHUB_REPO_PATTERN.test(repo) && !INVALID_GITHUB_REPO_NAMES.has(repo)
|
||||
}
|
||||
|
||||
function isValidGitHubRef(ref: string) {
|
||||
return (
|
||||
!!ref &&
|
||||
!CONTROL_CHARACTER_PATTERN.test(ref) &&
|
||||
!WHITESPACE_PATTERN.test(ref) &&
|
||||
!GITHUB_REF_OPTION_PATTERN.test(ref)
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path"
|
||||
import { resolveGitHubRegistrySource } from "@/src/registry/address"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
RegistryValidationError,
|
||||
} from "@/src/registry/errors"
|
||||
import { fetchRegistry } from "@/src/registry/fetcher"
|
||||
import { fetchGitHubRegistryCatalog } from "@/src/registry/github"
|
||||
import {
|
||||
fetchRegistryItems,
|
||||
resolveRegistryTree,
|
||||
@@ -55,6 +57,11 @@ export async function getRegistry(
|
||||
return parseRegistryCatalog(name, result)
|
||||
}
|
||||
|
||||
const githubSource = resolveGitHubRegistrySource(name)
|
||||
if (githubSource) {
|
||||
return fetchGitHubRegistryCatalog(githubSource, { useCache })
|
||||
}
|
||||
|
||||
if (!name.startsWith("@")) {
|
||||
throw new RegistryInvalidNamespaceError(name)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isGitHubItemAddress } from "@/src/registry/address"
|
||||
import { BUILTIN_REGISTRIES, REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { expandEnvVars } from "@/src/registry/env"
|
||||
import { RegistryNotConfiguredError } from "@/src/registry/errors"
|
||||
@@ -27,7 +28,12 @@ export function buildUrlAndHeadersForRegistryItem(
|
||||
// If no registry prefix, check if it's a URL or local path.
|
||||
// These should be handled directly, not through a registry.
|
||||
if (!registry) {
|
||||
if (isUrl(name) || isLocalFile(name) || isLocalPath(name)) {
|
||||
if (
|
||||
isUrl(name) ||
|
||||
isLocalFile(name) ||
|
||||
isLocalPath(name) ||
|
||||
isGitHubItemAddress(name)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
registry = "@shadcn"
|
||||
|
||||
@@ -236,6 +236,31 @@ export class RegistryLocalFileError extends RegistryError {
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistrySourceFileError extends RegistryError {
|
||||
constructor(
|
||||
public readonly filePath: string,
|
||||
cause?: unknown,
|
||||
options: {
|
||||
message?: string
|
||||
context?: Record<string, unknown>
|
||||
suggestion?: string
|
||||
} = {}
|
||||
) {
|
||||
super(
|
||||
options.message ?? `Failed to read registry source file: ${filePath}`,
|
||||
{
|
||||
code: RegistryErrorCode.FETCH_ERROR,
|
||||
cause,
|
||||
context: { filePath, ...options.context },
|
||||
suggestion:
|
||||
options.suggestion ??
|
||||
"Check if the source file exists and is accessible.",
|
||||
}
|
||||
)
|
||||
this.name = "RegistrySourceFileError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryParseError extends RegistryError {
|
||||
public readonly parseError: unknown
|
||||
|
||||
|
||||
184
packages/shadcn/src/registry/github-ref.test.ts
Normal file
184
packages/shadcn/src/registry/github-ref.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { execa } from "execa"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
getGitHubRefCandidates,
|
||||
getPreferredGitHubRefNames,
|
||||
parseGitLsRemote,
|
||||
resolveGitHubRef,
|
||||
} from "./github-ref"
|
||||
|
||||
vi.mock("execa", () => ({
|
||||
execa: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("GitHub ref resolution", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(execa).mockReset()
|
||||
})
|
||||
|
||||
it("resolves HEAD through git ls-remote", async () => {
|
||||
vi.mocked(execa).mockResolvedValueOnce({
|
||||
stdout: [
|
||||
"ref: refs/heads/main\tHEAD",
|
||||
"1111111111111111111111111111111111111111\tHEAD",
|
||||
].join("\n"),
|
||||
} as any)
|
||||
|
||||
await expect(resolveGitHubRef({ owner: "acme", repo: "ui" })).resolves.toBe(
|
||||
"1111111111111111111111111111111111111111"
|
||||
)
|
||||
expect(vi.mocked(execa)).toHaveBeenCalledWith(
|
||||
"git",
|
||||
["ls-remote", "--symref", "--", "https://github.com/acme/ui.git", "HEAD"],
|
||||
{
|
||||
env: {
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
timeout: 15_000,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("uses a full commit SHA without calling git", async () => {
|
||||
await expect(
|
||||
resolveGitHubRef({
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
ref: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
})
|
||||
).resolves.toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
expect(vi.mocked(execa)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("prefers branch names before tag names for shorthand refs", () => {
|
||||
expect(getPreferredGitHubRefNames("main")).toEqual([
|
||||
"refs/heads/main",
|
||||
"refs/tags/main^{}",
|
||||
"refs/tags/main",
|
||||
"main",
|
||||
])
|
||||
})
|
||||
|
||||
it("prefers peeled annotated tags over tag objects", async () => {
|
||||
vi.mocked(execa).mockResolvedValueOnce({
|
||||
stdout: [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/v1.0.0",
|
||||
"2222222222222222222222222222222222222222\trefs/tags/v1.0.0^{}",
|
||||
].join("\n"),
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
resolveGitHubRef({ owner: "acme", repo: "ui", ref: "v1.0.0" })
|
||||
).resolves.toBe("2222222222222222222222222222222222222222")
|
||||
})
|
||||
|
||||
it("treats short SHAs as refs", async () => {
|
||||
vi.mocked(execa).mockResolvedValueOnce({
|
||||
stdout: "",
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
resolveGitHubRef({ owner: "acme", repo: "ui", ref: "abc1234" })
|
||||
).rejects.toThrow('Could not resolve GitHub ref "abc1234"')
|
||||
expect(vi.mocked(execa)).toHaveBeenCalledWith(
|
||||
"git",
|
||||
[
|
||||
"ls-remote",
|
||||
"--symref",
|
||||
"--",
|
||||
"https://github.com/acme/ui.git",
|
||||
"refs/heads/abc1234",
|
||||
"refs/tags/abc1234^{}",
|
||||
"refs/tags/abc1234",
|
||||
"abc1234",
|
||||
],
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it("reuses command-local ref cache", async () => {
|
||||
const cache = new Map<string, Promise<string>>()
|
||||
vi.mocked(execa).mockResolvedValueOnce({
|
||||
stdout: "1111111111111111111111111111111111111111\tHEAD",
|
||||
} as any)
|
||||
|
||||
await resolveGitHubRef({ owner: "acme", repo: "ui" }, { cache })
|
||||
await resolveGitHubRef({ owner: "acme", repo: "ui" }, { cache })
|
||||
|
||||
expect(vi.mocked(execa)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("removes failed ref resolutions from the command-local cache", async () => {
|
||||
const cache = new Map<string, Promise<string>>()
|
||||
vi.mocked(execa)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("timed out"), { timedOut: true })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "1111111111111111111111111111111111111111\tHEAD",
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
resolveGitHubRef({ owner: "acme", repo: "ui" }, { cache })
|
||||
).rejects.toThrow('Failed to resolve GitHub ref "HEAD"')
|
||||
await expect(
|
||||
resolveGitHubRef({ owner: "acme", repo: "ui" }, { cache })
|
||||
).resolves.toBe("1111111111111111111111111111111111111111")
|
||||
|
||||
expect(vi.mocked(execa)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("returns a clear missing git suggestion", async () => {
|
||||
vi.mocked(execa).mockRejectedValueOnce(
|
||||
Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" })
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveGitHubRef({ owner: "acme", repo: "ui" })
|
||||
).rejects.toMatchObject({
|
||||
suggestion:
|
||||
"Install Git and try again. Git is required to resolve GitHub registry refs.",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns a clear timeout suggestion", async () => {
|
||||
vi.mocked(execa).mockRejectedValueOnce(
|
||||
Object.assign(new Error("timed out"), { timedOut: true })
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveGitHubRef({ owner: "acme", repo: "ui" })
|
||||
).rejects.toMatchObject({
|
||||
suggestion:
|
||||
"GitHub ref resolution timed out. Check your network connection and try again.",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitHub ls-remote parsing", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(execa).mockReset()
|
||||
})
|
||||
|
||||
it("parses advertised refs and skips symref lines", () => {
|
||||
const refs = parseGitLsRemote(
|
||||
[
|
||||
"ref: refs/heads/main\tHEAD",
|
||||
"1111111111111111111111111111111111111111\tHEAD",
|
||||
"2222222222222222222222222222222222222222\trefs/heads/main",
|
||||
].join("\n")
|
||||
)
|
||||
|
||||
expect(Object.fromEntries(refs)).toEqual({
|
||||
HEAD: "1111111111111111111111111111111111111111",
|
||||
"refs/heads/main": "2222222222222222222222222222222222222222",
|
||||
})
|
||||
})
|
||||
|
||||
it("deduplicates ref candidates", () => {
|
||||
expect(getGitHubRefCandidates("refs/heads/main")).toEqual([
|
||||
"refs/heads/main",
|
||||
])
|
||||
})
|
||||
})
|
||||
173
packages/shadcn/src/registry/github-ref.ts
Normal file
173
packages/shadcn/src/registry/github-ref.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type {
|
||||
ResolvedGitHubRegistrySource,
|
||||
ResolvedItemAddress,
|
||||
} from "@/src/registry/address"
|
||||
import { RegistrySourceFileError } from "@/src/registry/errors"
|
||||
import { execa } from "execa"
|
||||
|
||||
const GITHUB_URL = "https://github.com"
|
||||
const GITHUB_SHA_PATTERN = /^[a-fA-F0-9]{40}$/
|
||||
const GITHUB_REF_RESOLUTION_TIMEOUT = 15_000
|
||||
|
||||
type GitHubItemAddress = Extract<ResolvedItemAddress, { scheme: "github" }>
|
||||
export type GitHubSource = GitHubItemAddress | ResolvedGitHubRegistrySource
|
||||
|
||||
export type GitHubRefResolverOptions = {
|
||||
cache?: Map<string, Promise<string>>
|
||||
}
|
||||
|
||||
export async function resolveGitHubRef(
|
||||
address: GitHubSource,
|
||||
options: GitHubRefResolverOptions = {}
|
||||
) {
|
||||
const ref = address.ref ?? "HEAD"
|
||||
|
||||
if (GITHUB_SHA_PATTERN.test(ref)) {
|
||||
return ref.toLowerCase()
|
||||
}
|
||||
|
||||
const cacheKey = `${address.owner}/${address.repo}#${ref}`
|
||||
if (options.cache?.has(cacheKey)) {
|
||||
return options.cache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const promise = resolveGitHubRefUncached(address, ref).catch((error) => {
|
||||
options.cache?.delete(cacheKey)
|
||||
throw error
|
||||
})
|
||||
options.cache?.set(cacheKey, promise)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
async function resolveGitHubRefUncached(address: GitHubSource, ref: string) {
|
||||
const repoUrl = `${GITHUB_URL}/${address.owner}/${address.repo}.git`
|
||||
const candidates = getGitHubRefCandidates(ref)
|
||||
|
||||
let stdout: string
|
||||
try {
|
||||
const result = await execa(
|
||||
"git",
|
||||
["ls-remote", "--symref", "--", repoUrl, ...candidates],
|
||||
{
|
||||
env: {
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
timeout: GITHUB_REF_RESOLUTION_TIMEOUT,
|
||||
}
|
||||
)
|
||||
stdout = result.stdout
|
||||
} catch (error) {
|
||||
throw createGitHubRefResolutionError(address, ref, repoUrl, error)
|
||||
}
|
||||
|
||||
const refs = parseGitLsRemote(stdout)
|
||||
for (const candidate of getPreferredGitHubRefNames(ref)) {
|
||||
const sha = refs.get(candidate)
|
||||
if (sha) {
|
||||
return sha
|
||||
}
|
||||
}
|
||||
|
||||
throw new RegistrySourceFileError("registry.json", undefined, {
|
||||
message: `Could not resolve GitHub ref "${ref}" for ${address.owner}/${address.repo}.`,
|
||||
context: {
|
||||
reason: "github-ref-resolution",
|
||||
source: formatGitHubSource(address),
|
||||
ref,
|
||||
repoUrl,
|
||||
},
|
||||
suggestion:
|
||||
'Use an existing branch, tag, or full commit SHA. For example: "owner/repo/item#main" or "owner/repo/item#v1.0.0".',
|
||||
})
|
||||
}
|
||||
|
||||
export function getGitHubRefCandidates(ref: string) {
|
||||
return Array.from(new Set(getPreferredGitHubRefNames(ref)))
|
||||
}
|
||||
|
||||
export function getPreferredGitHubRefNames(ref: string) {
|
||||
if (ref === "HEAD") {
|
||||
return ["HEAD"]
|
||||
}
|
||||
|
||||
if (ref.startsWith("refs/tags/")) {
|
||||
return [`${ref}^{}`, ref]
|
||||
}
|
||||
|
||||
if (ref.startsWith("refs/")) {
|
||||
return [ref]
|
||||
}
|
||||
|
||||
return [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref]
|
||||
}
|
||||
|
||||
export function parseGitLsRemote(stdout: string) {
|
||||
const refs = new Map<string, string>()
|
||||
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("ref:")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const [sha, ref] = trimmed.split(/\s+/)
|
||||
if (sha && ref && GITHUB_SHA_PATTERN.test(sha)) {
|
||||
refs.set(ref, sha.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
function createGitHubRefResolutionError(
|
||||
address: GitHubSource,
|
||||
ref: string,
|
||||
repoUrl: string,
|
||||
error: unknown
|
||||
) {
|
||||
return new RegistrySourceFileError("registry.json", error, {
|
||||
message: `Failed to resolve GitHub ref "${ref}" for ${address.owner}/${address.repo}.`,
|
||||
context: {
|
||||
reason: "github-ref-resolution",
|
||||
source: formatGitHubSource(address),
|
||||
ref,
|
||||
repoUrl,
|
||||
},
|
||||
suggestion: getGitHubRefResolutionSuggestion(error),
|
||||
})
|
||||
}
|
||||
|
||||
function getGitHubRefResolutionSuggestion(error: unknown) {
|
||||
if (isGitNotFoundError(error)) {
|
||||
return "Install Git and try again. Git is required to resolve GitHub registry refs."
|
||||
}
|
||||
|
||||
if (isTimeoutError(error)) {
|
||||
return "GitHub ref resolution timed out. Check your network connection and try again."
|
||||
}
|
||||
|
||||
return "Check that the public GitHub repository exists and the ref is accessible."
|
||||
}
|
||||
|
||||
function isGitNotFoundError(error: unknown) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
)
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"timedOut" in error &&
|
||||
error.timedOut === true
|
||||
)
|
||||
}
|
||||
|
||||
function formatGitHubSource(address: GitHubSource) {
|
||||
return `${address.owner}/${address.repo}#${address.ref ?? "HEAD"}`
|
||||
}
|
||||
920
packages/shadcn/src/registry/github.test.ts
Normal file
920
packages/shadcn/src/registry/github.test.ts
Normal file
@@ -0,0 +1,920 @@
|
||||
import { execa } from "execa"
|
||||
import { http, HttpResponse } from "msw"
|
||||
import { setupServer } from "msw/node"
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest"
|
||||
|
||||
import { RegistrySourceFileError, RegistryValidationError } from "./errors"
|
||||
import { validateGitHubRegistrySource } from "./github"
|
||||
import {
|
||||
fetchRegistryItems,
|
||||
resolveRegistryItemsFromRegistries,
|
||||
resolveRegistryTree,
|
||||
} from "./resolver"
|
||||
import { searchRegistries } from "./search"
|
||||
|
||||
vi.mock("execa", () => ({
|
||||
execa: vi.fn(),
|
||||
}))
|
||||
|
||||
const server = setupServer()
|
||||
const HEAD_SHA = "1111111111111111111111111111111111111111"
|
||||
const TAG_SHA = "2222222222222222222222222222222222222222"
|
||||
const TAG_OBJECT_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
const BRANCH_SHA = "3333333333333333333333333333333333333333"
|
||||
const V1_SHA = "4444444444444444444444444444444444444444"
|
||||
const FULL_SHA = "5555555555555555555555555555555555555555"
|
||||
|
||||
describe("GitHub registry items", () => {
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: [
|
||||
`ref: refs/heads/main\tHEAD`,
|
||||
`${HEAD_SHA}\tHEAD`,
|
||||
`${HEAD_SHA}\trefs/heads/main`,
|
||||
`${TAG_OBJECT_SHA}\trefs/tags/v1.2.0`,
|
||||
`${TAG_SHA}\trefs/tags/v1.2.0^{}`,
|
||||
`${BRANCH_SHA}\trefs/heads/feature/forms`,
|
||||
`${V1_SHA}\trefs/tags/v1.0.0`,
|
||||
].join("\n"),
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.mocked(execa).mockReset()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
it("fetches an item from a GitHub source registry with include", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
include: ["components/ui/registry.json"],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Button() {}")
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const [item] = await fetchRegistryItems(["acme/ui/button"], {} as any, {
|
||||
useCache: false,
|
||||
})
|
||||
|
||||
expect(item).toMatchObject({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
content: "export function Button() {}",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(vi.mocked(execa)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("fetches an item from an explicit GitHub ref", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/2222222222222222222222222222222222222222/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/2222222222222222222222222222222222222222/button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Button() {}")
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const [item] = await fetchRegistryItems(
|
||||
["acme/ui/button#v1.2.0"],
|
||||
{} as any
|
||||
)
|
||||
|
||||
expect(item.files?.[0]?.path).toBe("button.tsx")
|
||||
})
|
||||
|
||||
it("uses a full commit SHA without resolving refs through git", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/5555555555555555555555555555555555555555/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const [item] = await fetchRegistryItems(
|
||||
[`acme/ui/button#${FULL_SHA}`],
|
||||
{} as any
|
||||
)
|
||||
|
||||
expect(item.name).toBe("button")
|
||||
expect(vi.mocked(execa)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("encodes GitHub refs with slashes", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/3333333333333333333333333333333333333333/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const [item] = await fetchRegistryItems(
|
||||
["acme/ui/button#feature/forms"],
|
||||
{} as any
|
||||
)
|
||||
|
||||
expect(item.name).toBe("button")
|
||||
})
|
||||
|
||||
it("fails when a GitHub ref cannot be resolved", async () => {
|
||||
vi.mocked(execa).mockResolvedValueOnce({ stdout: "" } as any)
|
||||
|
||||
await expect(
|
||||
fetchRegistryItems(["acme/ui/button#missing"], {} as any)
|
||||
).rejects.toThrow('Could not resolve GitHub ref "missing"')
|
||||
})
|
||||
|
||||
it("fails when the root registry.json file is missing", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchRegistryItems(["acme/ui/button"], {} as any)
|
||||
).rejects.toThrow(RegistrySourceFileError)
|
||||
await expect(
|
||||
fetchRegistryItems(["acme/ui/button"], {} as any)
|
||||
).rejects.toThrow("registry.json")
|
||||
})
|
||||
|
||||
it("explains raw.githubusercontent.com failures after ref resolution", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.error()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchRegistryItems(["acme/ui/button"], {} as any)
|
||||
).rejects.toMatchObject({
|
||||
suggestion:
|
||||
"GitHub ref resolution succeeded, but the CLI could not fetch from raw.githubusercontent.com. Check that raw.githubusercontent.com is accessible from this network.",
|
||||
})
|
||||
})
|
||||
|
||||
it("validates root registry item file paths", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "../button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchRegistryItems(["acme/ui/button"], {} as any)
|
||||
).rejects.toThrow(RegistryValidationError)
|
||||
})
|
||||
|
||||
it("resolves explicit GitHub dependencies and keeps bare dependencies as shadcn items", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "login-form",
|
||||
type: "registry:block",
|
||||
registryDependencies: ["acme/ui/button", "input"],
|
||||
files: [
|
||||
{
|
||||
path: "login-form.tsx",
|
||||
type: "registry:block",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/login-form.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function LoginForm() {}")
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Button() {}")
|
||||
}
|
||||
),
|
||||
http.get("https://ui.shadcn.com/r/index.json", () => {
|
||||
return HttpResponse.json([
|
||||
{
|
||||
name: "input",
|
||||
type: "registry:ui",
|
||||
registryDependencies: [],
|
||||
},
|
||||
])
|
||||
}),
|
||||
http.get("https://ui.shadcn.com/r/styles/new-york-v4/input.json", () => {
|
||||
return HttpResponse.json({
|
||||
name: "input",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "input.tsx",
|
||||
type: "registry:ui",
|
||||
content: "export function Input() {}",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const result = await resolveRegistryTree(["acme/ui/login-form"], {
|
||||
style: "new-york-v4",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindCss: "globals.css",
|
||||
tailwindConfig: "tailwind.config.js",
|
||||
components: "components",
|
||||
ui: "components/ui",
|
||||
lib: "lib",
|
||||
utils: "lib/utils",
|
||||
hooks: "hooks",
|
||||
},
|
||||
} as any)
|
||||
|
||||
expect(result?.files?.map((file) => file.path)).toEqual([
|
||||
"button.tsx",
|
||||
"input.tsx",
|
||||
"login-form.tsx",
|
||||
])
|
||||
})
|
||||
|
||||
it("resolves tagged and SHA-pinned GitHub dependencies", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "login-form",
|
||||
type: "registry:block",
|
||||
registryDependencies: [
|
||||
"acme/ui/button#v1.2.0",
|
||||
`acme/ui/card#${FULL_SHA}`,
|
||||
],
|
||||
files: [
|
||||
{
|
||||
path: "login-form.tsx",
|
||||
type: "registry:block",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/login-form.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function LoginForm() {}")
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/2222222222222222222222222222222222222222/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/2222222222222222222222222222222222222222/button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Button() {}")
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/5555555555555555555555555555555555555555/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "card.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/5555555555555555555555555555555555555555/card.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Card() {}")
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await resolveRegistryTree(["acme/ui/login-form"], {
|
||||
style: "new-york-v4",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindCss: "globals.css",
|
||||
tailwindConfig: "tailwind.config.js",
|
||||
components: "components",
|
||||
ui: "components/ui",
|
||||
lib: "lib",
|
||||
utils: "lib/utils",
|
||||
hooks: "hooks",
|
||||
},
|
||||
} as any)
|
||||
|
||||
expect(result?.files?.map((file) => file.path)).toEqual([
|
||||
"button.tsx",
|
||||
"card.tsx",
|
||||
"login-form.tsx",
|
||||
])
|
||||
expect(vi.mocked(execa)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("keeps same-name GitHub dependencies from different repositories distinct", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/app/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-app",
|
||||
homepage: "https://github.com/acme/app",
|
||||
items: [
|
||||
{
|
||||
name: "page",
|
||||
type: "registry:block",
|
||||
registryDependencies: ["acme/ui/button", "craft/ui/button"],
|
||||
files: [
|
||||
{
|
||||
path: "page.tsx",
|
||||
type: "registry:block",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/app/1111111111111111111111111111111111111111/page.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Page() {}")
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function AcmeButton() {}")
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/craft/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "craft-ui",
|
||||
homepage: "https://github.com/craft/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "craft-button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/craft/ui/1111111111111111111111111111111111111111/craft-button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function CraftButton() {}")
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await resolveRegistryTree(["acme/app/page"], {
|
||||
style: "new-york-v4",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindCss: "globals.css",
|
||||
tailwindConfig: "tailwind.config.js",
|
||||
components: "components",
|
||||
ui: "components/ui",
|
||||
lib: "lib",
|
||||
utils: "lib/utils",
|
||||
hooks: "hooks",
|
||||
},
|
||||
} as any)
|
||||
|
||||
expect(result?.files?.map((file) => file.path)).toEqual([
|
||||
"button.tsx",
|
||||
"craft-button.tsx",
|
||||
"page.tsx",
|
||||
])
|
||||
})
|
||||
|
||||
it("validates a GitHub source registry with include and checks item files", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
include: ["components/ui/registry.json"],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/button.tsx",
|
||||
() => {
|
||||
return HttpResponse.text("export function Button() {}")
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await validateGitHubRegistrySource({
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
valid: true,
|
||||
cwd: "acme/ui#HEAD",
|
||||
registryFiles: 2,
|
||||
registryFilePaths: [
|
||||
"acme/ui#HEAD/registry.json",
|
||||
"acme/ui#HEAD/components/ui/registry.json",
|
||||
],
|
||||
items: 1,
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("bounds item validation concurrency for GitHub source registries", async () => {
|
||||
const items = Array.from({ length: 16 }, (_, index) => ({
|
||||
name: `item-${index}`,
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: `item-${index}.tsx`,
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
}))
|
||||
let activeRequests = 0
|
||||
let maxActiveRequests = 0
|
||||
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/:file",
|
||||
async ({ params }) => {
|
||||
const file = String(params.file)
|
||||
|
||||
if (file === "registry.json") {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
activeRequests += 1
|
||||
maxActiveRequests = Math.max(maxActiveRequests, activeRequests)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
activeRequests -= 1
|
||||
|
||||
return HttpResponse.text(
|
||||
`export const ${file.replace(/\W/g, "_")} = {}`
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await validateGitHubRegistrySource({
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
})
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.items).toBe(items.length)
|
||||
expect(maxActiveRequests).toBeLessThanOrEqual(8)
|
||||
expect(maxActiveRequests).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("reports duplicate item names in a root GitHub source registry", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
},
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await validateGitHubRegistrySource({
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
})
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
registryFile: "acme/ui#HEAD/registry.json",
|
||||
message: expect.stringContaining(
|
||||
'Duplicate registry item name "button"'
|
||||
),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("reports invalid GitHub source registry item files", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
include: ["components/ui/registry.json"],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "missing.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/missing.tsx",
|
||||
() => {
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await validateGitHubRegistrySource({
|
||||
owner: "acme",
|
||||
repo: "ui",
|
||||
})
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
registryFile: "acme/ui#HEAD/components/ui/registry.json",
|
||||
itemName: "button",
|
||||
itemIndex: 0,
|
||||
filePath: "missing.tsx",
|
||||
suggestion:
|
||||
"Check that the file path exists in the public GitHub repository.",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("does not rewrite GitHub addresses as shadcn registry items", () => {
|
||||
const result = resolveRegistryItemsFromRegistries(
|
||||
["acme/ui/button", "button", "@acme/card"],
|
||||
{
|
||||
registries: {
|
||||
"@acme": "https://example.com/{name}.json",
|
||||
},
|
||||
} as any
|
||||
)
|
||||
|
||||
expect(result).toEqual([
|
||||
"acme/ui/button",
|
||||
"https://ui.shadcn.com/r/styles/{style}/button.json",
|
||||
"https://example.com/card.json",
|
||||
])
|
||||
})
|
||||
|
||||
it("lists items from a GitHub source registry", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
include: ["components/ui/registry.json"],
|
||||
})
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/1111111111111111111111111111111111111111/components/ui/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await searchRegistries(["acme/ui"])
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
registry: "acme/ui",
|
||||
addCommandArgument: "acme/ui/button",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
registry: "acme/ui",
|
||||
addCommandArgument: "acme/ui/card",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("searches items from a GitHub source registry with an explicit ref", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"https://raw.githubusercontent.com/acme/ui/4444444444444444444444444444444444444444/registry.json",
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
name: "acme-ui",
|
||||
homepage: "https://github.com/acme/ui",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
},
|
||||
{
|
||||
name: "dialog",
|
||||
type: "registry:ui",
|
||||
description: "A modal component",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const result = await searchRegistries(["acme/ui#v1.0.0"], {
|
||||
query: "button",
|
||||
})
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
registry: "acme/ui#v1.0.0",
|
||||
addCommandArgument: "acme/ui/button#v1.0.0",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
313
packages/shadcn/src/registry/github.ts
Normal file
313
packages/shadcn/src/registry/github.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
ResolvedGitHubRegistrySource,
|
||||
ResolvedItemAddress,
|
||||
} from "@/src/registry/address"
|
||||
import { RegistryError, RegistrySourceFileError } from "@/src/registry/errors"
|
||||
import { resolveGitHubRef } from "@/src/registry/github-ref"
|
||||
import type { GitHubSource } from "@/src/registry/github-ref"
|
||||
import {
|
||||
loadRegistryCatalogFromSource,
|
||||
loadRegistryItemFromSource,
|
||||
} from "@/src/registry/source"
|
||||
import type { RegistrySourceReader } from "@/src/registry/source"
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import fetch, { Headers } from "node-fetch"
|
||||
|
||||
const GITHUB_RAW_URL = "https://raw.githubusercontent.com"
|
||||
const GITHUB_VALIDATION_CONCURRENCY = 8
|
||||
|
||||
const agent = process.env.https_proxy
|
||||
? new HttpsProxyAgent(process.env.https_proxy)
|
||||
: undefined
|
||||
|
||||
type GitHubItemAddress = Extract<ResolvedItemAddress, { scheme: "github" }>
|
||||
|
||||
type GitHubRegistryValidationDiagnostic = {
|
||||
registryFile: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
itemName?: string
|
||||
itemIndex?: number
|
||||
includePath?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export type GitHubSourceOptions = {
|
||||
useCache?: boolean
|
||||
sourceCache?: Map<string, Promise<string>>
|
||||
}
|
||||
|
||||
export async function fetchGitHubRegistryItem(
|
||||
address: GitHubItemAddress,
|
||||
options: GitHubSourceOptions = {}
|
||||
) {
|
||||
options = {
|
||||
...options,
|
||||
sourceCache: options.sourceCache ?? new Map(),
|
||||
}
|
||||
|
||||
const reader = createGitHubRegistrySourceReader(address, options)
|
||||
|
||||
return loadRegistryItemFromSource(address.item, reader, {
|
||||
source: formatGitHubSource(address),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchGitHubRegistryCatalog(
|
||||
source: ResolvedGitHubRegistrySource,
|
||||
options: GitHubSourceOptions = {}
|
||||
) {
|
||||
options = {
|
||||
...options,
|
||||
sourceCache: options.sourceCache ?? new Map(),
|
||||
}
|
||||
|
||||
const reader = createGitHubRegistrySourceReader(source, options)
|
||||
|
||||
return loadRegistryCatalogFromSource(reader, {
|
||||
source: formatGitHubSource(source),
|
||||
})
|
||||
}
|
||||
|
||||
export async function validateGitHubRegistrySource(
|
||||
source: ResolvedGitHubRegistrySource,
|
||||
options: GitHubSourceOptions = {}
|
||||
) {
|
||||
const sourceLabel = formatGitHubSource(source)
|
||||
const registryFile = `${sourceLabel}/registry.json`
|
||||
const registryFiles = new Set<string>()
|
||||
const sourceCache = options.sourceCache ?? new Map<string, Promise<string>>()
|
||||
const sourceOptions = {
|
||||
...options,
|
||||
sourceCache,
|
||||
}
|
||||
const sourceReader = createGitHubRegistrySourceReader(source, sourceOptions)
|
||||
const trackingReader: RegistrySourceReader = {
|
||||
async readText(filePath) {
|
||||
if (filePath.endsWith("registry.json")) {
|
||||
registryFiles.add(`${sourceLabel}/${filePath}`)
|
||||
}
|
||||
|
||||
return sourceReader.readText(filePath)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const registry = await loadRegistryCatalogFromSource(trackingReader, {
|
||||
source: sourceLabel,
|
||||
})
|
||||
const itemDiagnostics = await mapWithConcurrency(
|
||||
registry.items,
|
||||
GITHUB_VALIDATION_CONCURRENCY,
|
||||
async (item, itemIndex) => {
|
||||
try {
|
||||
await loadRegistryItemFromSource(item.name, trackingReader, {
|
||||
source: sourceLabel,
|
||||
})
|
||||
return null
|
||||
} catch (error) {
|
||||
return createGitHubValidationDiagnostic(error, {
|
||||
defaultRegistryFile: registryFile,
|
||||
itemName: item.name,
|
||||
itemIndex,
|
||||
sourceLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
const diagnostics = itemDiagnostics.filter(
|
||||
(diagnostic): diagnostic is GitHubRegistryValidationDiagnostic =>
|
||||
diagnostic !== null
|
||||
)
|
||||
|
||||
return {
|
||||
valid: diagnostics.length === 0,
|
||||
cwd: sourceLabel,
|
||||
registryFiles: registryFiles.size,
|
||||
registryFilePaths: Array.from(registryFiles),
|
||||
items: registry.items.length,
|
||||
diagnostics,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
cwd: sourceLabel,
|
||||
registryFiles: registryFiles.size || 1,
|
||||
registryFilePaths: registryFiles.size
|
||||
? Array.from(registryFiles)
|
||||
: [registryFile],
|
||||
items: 0,
|
||||
diagnostics: [
|
||||
createGitHubValidationDiagnostic(error, {
|
||||
defaultRegistryFile: registryFile,
|
||||
sourceLabel,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createGitHubRegistrySourceReader(
|
||||
address: GitHubSource,
|
||||
options: GitHubSourceOptions
|
||||
) {
|
||||
const shaPromise = resolveGitHubRef(address, {
|
||||
cache: options.sourceCache,
|
||||
})
|
||||
|
||||
return {
|
||||
async readText(filePath: string) {
|
||||
const sha = await shaPromise
|
||||
const url = buildGitHubRawUrl(address, sha, filePath)
|
||||
|
||||
if (options.useCache !== false && options.sourceCache?.has(url)) {
|
||||
return options.sourceCache.get(url)!
|
||||
}
|
||||
|
||||
const promise = fetchGitHubSourceFile(url, filePath, address)
|
||||
|
||||
if (options.useCache !== false) {
|
||||
options.sourceCache?.set(url, promise)
|
||||
}
|
||||
|
||||
return promise
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubSourceFile(
|
||||
url: string,
|
||||
filePath: string,
|
||||
address: GitHubSource
|
||||
) {
|
||||
let response: Awaited<ReturnType<typeof fetch>>
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
agent,
|
||||
headers: new Headers({
|
||||
"Accept-Encoding": "identity",
|
||||
"User-Agent": "shadcn",
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new RegistrySourceFileError(filePath, error, {
|
||||
message: `Failed to read GitHub source file "${filePath}" from ${formatGitHubSource(
|
||||
address
|
||||
)}.`,
|
||||
context: {
|
||||
reason: "github-source-file",
|
||||
url,
|
||||
source: formatGitHubSource(address),
|
||||
filePath,
|
||||
},
|
||||
suggestion:
|
||||
"GitHub ref resolution succeeded, but the CLI could not fetch from raw.githubusercontent.com. Check that raw.githubusercontent.com is accessible from this network.",
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RegistrySourceFileError(filePath, undefined, {
|
||||
message: `Failed to read GitHub source file "${filePath}" from ${formatGitHubSource(
|
||||
address
|
||||
)}.`,
|
||||
context: {
|
||||
reason: "github-source-file",
|
||||
url,
|
||||
statusCode: response.status,
|
||||
source: formatGitHubSource(address),
|
||||
filePath,
|
||||
},
|
||||
suggestion:
|
||||
filePath === "registry.json"
|
||||
? "The GitHub repository and ref were resolved, but raw.githubusercontent.com did not return a root registry.json file. Check that the public repository has registry.json at its root and that raw.githubusercontent.com is accessible from this network."
|
||||
: "Check that the file path exists in the public GitHub repository.",
|
||||
})
|
||||
}
|
||||
|
||||
return response.text()
|
||||
}
|
||||
|
||||
function buildGitHubRawUrl(
|
||||
address: GitHubSource,
|
||||
resolvedSha: string,
|
||||
filePath: string
|
||||
) {
|
||||
const file = filePath
|
||||
.split("/")
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join("/")
|
||||
|
||||
return `${GITHUB_RAW_URL}/${address.owner}/${address.repo}/${resolvedSha}/${file}`
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>
|
||||
) {
|
||||
const results = new Array<TResult>(items.length)
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.min(concurrency, items.length)
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const itemIndex = nextIndex++
|
||||
results[itemIndex] = await mapper(items[itemIndex]!, itemIndex)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function formatGitHubSource(address: GitHubSource) {
|
||||
return `${address.owner}/${address.repo}#${address.ref ?? "HEAD"}`
|
||||
}
|
||||
|
||||
function createGitHubValidationDiagnostic(
|
||||
error: unknown,
|
||||
options: {
|
||||
defaultRegistryFile: string
|
||||
itemName?: string
|
||||
itemIndex?: number
|
||||
sourceLabel: string
|
||||
}
|
||||
) {
|
||||
if (error instanceof RegistryError) {
|
||||
const registryFile =
|
||||
typeof error.context?.registryFile === "string"
|
||||
? `${options.sourceLabel}/${error.context.registryFile}`
|
||||
: options.defaultRegistryFile
|
||||
const diagnostic: GitHubRegistryValidationDiagnostic = {
|
||||
registryFile,
|
||||
itemName: options.itemName,
|
||||
itemIndex:
|
||||
typeof error.context?.itemIndex === "number"
|
||||
? error.context.itemIndex
|
||||
: options.itemIndex,
|
||||
filePath:
|
||||
typeof error.context?.itemFilePath === "string"
|
||||
? error.context.itemFilePath
|
||||
: typeof error.context?.filePath === "string"
|
||||
? error.context.filePath
|
||||
: undefined,
|
||||
includePath:
|
||||
typeof error.context?.includePath === "string"
|
||||
? error.context.includePath
|
||||
: undefined,
|
||||
message: error.message,
|
||||
suggestion: error.suggestion,
|
||||
}
|
||||
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
const diagnostic: GitHubRegistryValidationDiagnostic = {
|
||||
registryFile: options.defaultRegistryFile,
|
||||
itemName: options.itemName,
|
||||
itemIndex: options.itemIndex,
|
||||
message: error instanceof Error ? error.message : "Unknown error.",
|
||||
}
|
||||
|
||||
return diagnostic
|
||||
}
|
||||
@@ -157,11 +157,11 @@ export async function createRegistryItem(
|
||||
$schema: "https://ui.shadcn.com/schema/registry-item.json",
|
||||
}
|
||||
|
||||
for (let index = 0; index < (item.files?.length ?? 0); index++) {
|
||||
const sourceFile = item.files?.[index]
|
||||
await Promise.all(
|
||||
(item.files ?? []).map(async (sourceFile, index) => {
|
||||
const file = registryItem.files?.[index]
|
||||
if (!file || !sourceFile) {
|
||||
continue
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const source = result.itemSourcesByItem.get(item)
|
||||
@@ -177,7 +177,8 @@ export async function createRegistryItem(
|
||||
sourcePath,
|
||||
source
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return registryItemSchema.parse(registryItem)
|
||||
}
|
||||
|
||||
@@ -460,6 +460,135 @@ describe("resolveRegistryItems with URL dependencies", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("should order URL dependencies by source when file name differs from item name", async () => {
|
||||
const componentUrl = "https://example.com/component-with-renamed-dep.json"
|
||||
const dependencyUrl = "https://example.com/renamed-dependency.json"
|
||||
|
||||
const mockServer = setupServer(
|
||||
http.get(componentUrl, () => {
|
||||
return HttpResponse.json({
|
||||
name: "component-with-renamed-dep",
|
||||
type: "registry:ui",
|
||||
registryDependencies: [dependencyUrl],
|
||||
files: [
|
||||
{
|
||||
path: "ui/component-with-renamed-dep.tsx",
|
||||
content: "// component content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
http.get(dependencyUrl, () => {
|
||||
return HttpResponse.json({
|
||||
name: "actual-dependency-name",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "ui/actual-dependency-name.tsx",
|
||||
content: "// dependency content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
mockServer.listen({ onUnhandledRequest: "bypass" })
|
||||
|
||||
try {
|
||||
const result = await resolveRegistryTree([componentUrl], {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
tailwindCss: "./globals.css",
|
||||
utils: "./lib/utils",
|
||||
components: "./components",
|
||||
lib: "./lib",
|
||||
hooks: "./hooks",
|
||||
ui: "./components/ui",
|
||||
},
|
||||
} as any)
|
||||
|
||||
expect(result?.files?.map((file) => file.path)).toEqual([
|
||||
"ui/actual-dependency-name.tsx",
|
||||
"ui/component-with-renamed-dep.tsx",
|
||||
])
|
||||
} finally {
|
||||
mockServer.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("should order local file dependencies by source when file name differs from item name", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "registry-test-"))
|
||||
const componentFile = path.join(tempDir, "component-with-renamed-dep.json")
|
||||
const dependencyFile = path.join(tempDir, "renamed-dependency.json")
|
||||
|
||||
await fs.writeFile(
|
||||
componentFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "component-with-renamed-dep",
|
||||
type: "registry:ui",
|
||||
registryDependencies: [dependencyFile],
|
||||
files: [
|
||||
{
|
||||
path: "ui/component-with-renamed-dep.tsx",
|
||||
content: "// component content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
await fs.writeFile(
|
||||
dependencyFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "actual-dependency-name",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "ui/actual-dependency-name.tsx",
|
||||
content: "// dependency content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await resolveRegistryTree([componentFile], {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
tailwindCss: "./globals.css",
|
||||
utils: "./lib/utils",
|
||||
components: "./components",
|
||||
lib: "./lib",
|
||||
hooks: "./hooks",
|
||||
ui: "./components/ui",
|
||||
},
|
||||
} as any)
|
||||
|
||||
expect(result?.files?.map((file) => file.path)).toEqual([
|
||||
"ui/actual-dependency-name.tsx",
|
||||
"ui/component-with-renamed-dep.tsx",
|
||||
])
|
||||
} finally {
|
||||
await fs.rm(tempDir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("should resolve namespace syntax in registryDependencies", async () => {
|
||||
// Mock a namespace registry endpoint
|
||||
const namespaceUrl = "https://custom-registry.com/custom-component.json"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "crypto"
|
||||
import path from "path"
|
||||
import { isGitHubItemAddress, resolveItemAddress } from "@/src/registry/address"
|
||||
import {
|
||||
getRegistryBaseColor,
|
||||
getShadcnRegistryIndex,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
RegistryParseError,
|
||||
} from "@/src/registry/errors"
|
||||
import { fetchRegistry, fetchRegistryLocal } from "@/src/registry/fetcher"
|
||||
import { fetchGitHubRegistryItem } from "@/src/registry/github"
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import {
|
||||
deduplicateFilesByTarget,
|
||||
@@ -34,6 +36,11 @@ import { buildTailwindThemeColorsFromCssVars } from "@/src/utils/updaters/update
|
||||
import deepmerge from "deepmerge"
|
||||
import { z } from "zod"
|
||||
|
||||
type RegistryFetchOptions = {
|
||||
useCache?: boolean
|
||||
sourceCache?: Map<string, Promise<string>>
|
||||
}
|
||||
|
||||
export function resolveRegistryItemsFromRegistries(
|
||||
items: string[],
|
||||
config: Config
|
||||
@@ -47,6 +54,10 @@ export function resolveRegistryItemsFromRegistries(
|
||||
}
|
||||
|
||||
for (let i = 0; i < resolvedItems.length; i++) {
|
||||
if (isGitHubItemAddress(resolvedItems[i])) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = buildUrlAndHeadersForRegistryItem(resolvedItems[i], config)
|
||||
|
||||
if (resolved) {
|
||||
@@ -68,10 +79,21 @@ export function resolveRegistryItemsFromRegistries(
|
||||
export async function fetchRegistryItems(
|
||||
items: string[],
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
options: RegistryFetchOptions = {}
|
||||
) {
|
||||
options = {
|
||||
...options,
|
||||
sourceCache: options.sourceCache ?? new Map(),
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const resolvedAddress = resolveItemAddress(item)
|
||||
|
||||
if (resolvedAddress.scheme === "github") {
|
||||
return fetchGitHubRegistryItem(resolvedAddress, options)
|
||||
}
|
||||
|
||||
if (isLocalFile(item)) {
|
||||
return fetchRegistryLocal(item)
|
||||
}
|
||||
@@ -124,11 +146,12 @@ const registryItemWithSourceSchema = registryItemCommonSchema
|
||||
export async function resolveRegistryTree(
|
||||
names: z.infer<typeof registryItemSchema>["name"][],
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
options: RegistryFetchOptions = {}
|
||||
) {
|
||||
options = {
|
||||
useCache: true,
|
||||
...options,
|
||||
sourceCache: options.sourceCache ?? new Map(),
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
@@ -366,10 +389,10 @@ export async function resolveRegistryTree(
|
||||
async function resolveDependenciesRecursively(
|
||||
dependencies: string[],
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {},
|
||||
options: RegistryFetchOptions = {},
|
||||
visited: Set<string> = new Set()
|
||||
) {
|
||||
const items: z.infer<typeof registryItemSchema>[] = []
|
||||
const items: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
const registryNames: string[] = []
|
||||
|
||||
for (const dep of dependencies) {
|
||||
@@ -378,11 +401,43 @@ async function resolveDependenciesRecursively(
|
||||
}
|
||||
visited.add(dep)
|
||||
|
||||
const resolvedAddress = resolveItemAddress(dep)
|
||||
|
||||
// Handle URLs and local files directly.
|
||||
if (isUrl(dep) || isLocalFile(dep)) {
|
||||
if (resolvedAddress.scheme === "github") {
|
||||
const [item] = await fetchRegistryItems([dep], config, options)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
items.push({
|
||||
...item,
|
||||
_source: dep,
|
||||
})
|
||||
if (item.registryDependencies) {
|
||||
const resolvedDeps = config?.registries
|
||||
? resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
: item.registryDependencies
|
||||
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
resolvedDeps,
|
||||
config,
|
||||
options,
|
||||
visited
|
||||
)
|
||||
items.push(...nested.items)
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle URLs and local files directly.
|
||||
else if (isUrl(dep) || isLocalFile(dep)) {
|
||||
const [item] = await fetchRegistryItems([dep], config, options)
|
||||
if (item) {
|
||||
items.push({
|
||||
...item,
|
||||
_source: dep,
|
||||
})
|
||||
if (item.registryDependencies) {
|
||||
// Resolve namespaced dependencies to set proper headers.
|
||||
const resolvedDeps = config?.registries
|
||||
@@ -475,7 +530,7 @@ async function resolveDependenciesRecursively(
|
||||
async function resolveRegistryDependencies(
|
||||
url: string,
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
options: RegistryFetchOptions = {}
|
||||
) {
|
||||
if (isUrl(url)) {
|
||||
return [url]
|
||||
@@ -590,6 +645,15 @@ function computeItemHash(
|
||||
}
|
||||
|
||||
function extractItemIdentifierFromDependency(dependency: string) {
|
||||
const resolvedAddress = resolveItemAddress(dependency)
|
||||
|
||||
if (resolvedAddress.scheme === "github") {
|
||||
return {
|
||||
name: resolvedAddress.item,
|
||||
hash: computeItemHash({ name: resolvedAddress.item }, dependency),
|
||||
}
|
||||
}
|
||||
|
||||
if (isUrl(dependency)) {
|
||||
const url = new URL(dependency)
|
||||
const pathname = url.pathname
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Config } from "@/src/utils/get-config"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { z } from "zod"
|
||||
|
||||
import { resolveGitHubRegistrySource } from "./address"
|
||||
import { getRegistry } from "./api"
|
||||
|
||||
export async function searchRegistries(
|
||||
@@ -120,6 +121,12 @@ export function buildRegistryItemNameFromRegistry(
|
||||
name: string,
|
||||
registry: string
|
||||
) {
|
||||
const githubSource = resolveGitHubRegistrySource(registry)
|
||||
if (githubSource) {
|
||||
const itemAddress = `${githubSource.owner}/${githubSource.repo}/${name}`
|
||||
return githubSource.ref ? `${itemAddress}#${githubSource.ref}` : itemAddress
|
||||
}
|
||||
|
||||
// If registry is not a URL, return namespace format.
|
||||
if (!isUrl(registry)) {
|
||||
return `${registry}/${name}`
|
||||
|
||||
655
packages/shadcn/src/registry/source.ts
Normal file
655
packages/shadcn/src/registry/source.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import path from "path"
|
||||
import {
|
||||
RegistryItemNotFoundError,
|
||||
RegistryParseError,
|
||||
RegistrySourceFileError,
|
||||
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>
|
||||
|
||||
type RegistryItemSource = {
|
||||
registryFile: string
|
||||
registryDir: string
|
||||
itemIndex: number
|
||||
}
|
||||
|
||||
type SourceRegistryLoadResult = {
|
||||
registry: Registry
|
||||
itemSources: Map<string, RegistryItemSource>
|
||||
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
|
||||
usesInclude: boolean
|
||||
}
|
||||
|
||||
export type RegistrySourceReader = {
|
||||
readText(filePath: string): Promise<string>
|
||||
}
|
||||
|
||||
export async function loadRegistryItemFromSource(
|
||||
itemName: string,
|
||||
reader: RegistrySourceReader,
|
||||
options: {
|
||||
registryFile?: string
|
||||
source?: string
|
||||
} = {}
|
||||
) {
|
||||
const registryFile = normalizeSourcePath(
|
||||
options.registryFile ?? "registry.json"
|
||||
)
|
||||
const result = await readSourceRegistryWithIncludes(registryFile, reader, {
|
||||
source: options.source,
|
||||
})
|
||||
const item = result.registry.items.find((item) => item.name === itemName)
|
||||
|
||||
if (!item) {
|
||||
throw new RegistryItemNotFoundError(itemName)
|
||||
}
|
||||
|
||||
return createRegistryItemFromSource(item, result, reader)
|
||||
}
|
||||
|
||||
export async function loadRegistryCatalogFromSource(
|
||||
reader: RegistrySourceReader,
|
||||
options: {
|
||||
registryFile?: string
|
||||
source?: string
|
||||
} = {}
|
||||
) {
|
||||
const registryFile = normalizeSourcePath(
|
||||
options.registryFile ?? "registry.json"
|
||||
)
|
||||
const result = await readSourceRegistryWithIncludes(registryFile, reader, {
|
||||
source: options.source,
|
||||
})
|
||||
|
||||
return createRegistryCatalogFromSource(result)
|
||||
}
|
||||
|
||||
async function readSourceRegistryWithIncludes(
|
||||
registryFile: string,
|
||||
reader: RegistrySourceReader,
|
||||
options: {
|
||||
source?: string
|
||||
} = {}
|
||||
) {
|
||||
const content = await readRegistryJson(registryFile, reader, options)
|
||||
const rootRegistry = parseRegistry(content, registryFile)
|
||||
validateRootRegistry(rootRegistry, registryFile)
|
||||
const context = {
|
||||
itemSources: new Map<string, RegistryItemSource>(),
|
||||
itemSourcesByItem: new Map<RegistryItem, RegistryItemSource>(),
|
||||
firstIncludedFrom: new Map<string, string>(),
|
||||
}
|
||||
const usesInclude = !!rootRegistry.include?.length
|
||||
|
||||
if (!usesInclude) {
|
||||
const registryDir = getSourceDir(registryFile)
|
||||
|
||||
rootRegistry.items.forEach((item, itemIndex) => {
|
||||
validateRegistryItemFiles(item, registryFile, registryDir)
|
||||
|
||||
const source = {
|
||||
registryFile,
|
||||
registryDir,
|
||||
itemIndex,
|
||||
}
|
||||
context.itemSources.set(item.name, source)
|
||||
context.itemSourcesByItem.set(item, source)
|
||||
})
|
||||
validateDuplicateItems(rootRegistry.items, context.itemSourcesByItem)
|
||||
|
||||
return {
|
||||
registry: rootRegistry,
|
||||
itemSources: context.itemSources,
|
||||
itemSourcesByItem: context.itemSourcesByItem,
|
||||
usesInclude,
|
||||
}
|
||||
}
|
||||
|
||||
if (path.posix.basename(registryFile) !== "registry.json") {
|
||||
throw new RegistryValidationError(
|
||||
`Invalid source registry file at ${registryFile}: registries that use include must be named registry.json.`,
|
||||
{ registryFile }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await readRegistryFile(
|
||||
registryFile,
|
||||
rootRegistry,
|
||||
reader,
|
||||
context,
|
||||
[]
|
||||
)
|
||||
|
||||
validateDuplicateItems(result.items, context.itemSourcesByItem)
|
||||
|
||||
const { include, ...registry } = result
|
||||
validateRootRegistry(registry, registryFile)
|
||||
|
||||
return {
|
||||
registry,
|
||||
itemSources: context.itemSources,
|
||||
itemSourcesByItem: context.itemSourcesByItem,
|
||||
usesInclude,
|
||||
}
|
||||
}
|
||||
|
||||
async function createRegistryItemFromSource(
|
||||
item: RegistryItem,
|
||||
result: SourceRegistryLoadResult,
|
||||
reader: RegistrySourceReader
|
||||
) {
|
||||
const registryItem = {
|
||||
...rewriteRegistryItemFilePaths(item, result.itemSourcesByItem),
|
||||
$schema: "https://ui.shadcn.com/schema/registry-item.json",
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
(item.files ?? []).map(async (sourceFile, index) => {
|
||||
const file = registryItem.files?.[index]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const source = result.itemSourcesByItem.get(item)
|
||||
const sourcePath = getRegistryItemFileSourceForItem(
|
||||
item,
|
||||
sourceFile.path,
|
||||
result.itemSourcesByItem
|
||||
)
|
||||
file.content = await readRegistryItemFileContent(
|
||||
item.name,
|
||||
sourceFile.path,
|
||||
sourcePath,
|
||||
source,
|
||||
reader
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
return registryItemSchema.parse(registryItem)
|
||||
}
|
||||
|
||||
function createRegistryCatalogFromSource(result: SourceRegistryLoadResult) {
|
||||
return {
|
||||
...result.registry,
|
||||
items: result.registry.items.map((item) =>
|
||||
stripRegistryItemFileContent(
|
||||
rewriteRegistryItemFilePaths(item, result.itemSourcesByItem)
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async function readRegistryItemFileContent(
|
||||
itemName: string,
|
||||
filePath: string,
|
||||
sourcePath: string,
|
||||
source: RegistryItemSource | undefined,
|
||||
reader: RegistrySourceReader
|
||||
) {
|
||||
try {
|
||||
return await reader.readText(sourcePath)
|
||||
} catch (error) {
|
||||
const isGitHubSourceFileError =
|
||||
error instanceof RegistrySourceFileError &&
|
||||
error.context?.reason === "github-source-file"
|
||||
|
||||
throw new RegistrySourceFileError(sourcePath, error, {
|
||||
message: `Failed to read file "${filePath}" for registry item "${itemName}" (${formatItemSource(
|
||||
source
|
||||
)}). Expected file at ${sourcePath}.`,
|
||||
context: {
|
||||
registryFile: source?.registryFile,
|
||||
itemIndex: source?.itemIndex,
|
||||
itemName,
|
||||
itemFilePath: filePath,
|
||||
sourcePath,
|
||||
},
|
||||
suggestion:
|
||||
isGitHubSourceFileError && error.suggestion
|
||||
? error.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>
|
||||
) {
|
||||
return {
|
||||
...item,
|
||||
files: item.files?.map((file) => ({
|
||||
...file,
|
||||
path: getRegistryItemFileRootPathForItem(
|
||||
item,
|
||||
file.path,
|
||||
itemSourcesByItem
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function stripRegistryItemFileContent(item: RegistryItem) {
|
||||
return {
|
||||
...item,
|
||||
files: item.files?.map(({ content, ...file }) => file),
|
||||
}
|
||||
}
|
||||
|
||||
function getRegistryItemFileSourceForItem(
|
||||
item: RegistryItem,
|
||||
filePath: string,
|
||||
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
|
||||
) {
|
||||
const source = itemSourcesByItem.get(item)
|
||||
return joinSourcePath(source?.registryDir ?? ".", filePath)
|
||||
}
|
||||
|
||||
function getRegistryItemFileRootPathForItem(
|
||||
item: RegistryItem,
|
||||
filePath: string,
|
||||
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
|
||||
) {
|
||||
const sourcePath = getRegistryItemFileSourceForItem(
|
||||
item,
|
||||
filePath,
|
||||
itemSourcesByItem
|
||||
)
|
||||
|
||||
return relativeSourcePath(".", sourcePath)
|
||||
}
|
||||
|
||||
async function readRegistryFile(
|
||||
registryFile: string,
|
||||
registry: RegistryChunk,
|
||||
reader: RegistrySourceReader,
|
||||
context: {
|
||||
itemSources: Map<string, RegistryItemSource>
|
||||
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
|
||||
firstIncludedFrom: Map<string, string>
|
||||
},
|
||||
chain: string[]
|
||||
): Promise<RegistryChunk> {
|
||||
validateRegistryFileWithinRoot(registryFile)
|
||||
|
||||
if (chain.length >= 32) {
|
||||
throw new RegistryValidationError(
|
||||
`Registry include tree is too deep at ${registryFile}. The maximum include depth is 32.`,
|
||||
{
|
||||
registryFile,
|
||||
context: {
|
||||
maxDepth: 32,
|
||||
},
|
||||
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 = getSourceDir(registryFile)
|
||||
|
||||
const includedItems: RegistryItem[] = []
|
||||
for (const includePath of registry.include ?? []) {
|
||||
const includedRegistryFile = resolveIncludePath(
|
||||
includePath,
|
||||
registryDir,
|
||||
registryFile
|
||||
)
|
||||
const content = await readRegistryJson(includedRegistryFile, reader)
|
||||
const parsedRegistry = parseRegistry(content, includedRegistryFile)
|
||||
const includedRegistry = await readRegistryFile(
|
||||
includedRegistryFile,
|
||||
parsedRegistry,
|
||||
reader,
|
||||
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,
|
||||
reader: RegistrySourceReader,
|
||||
options: {
|
||||
source?: string
|
||||
} = {}
|
||||
) {
|
||||
try {
|
||||
return await reader.readText(registryFile)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof RegistrySourceFileError &&
|
||||
(error.context?.reason === "github-ref-resolution" ||
|
||||
error.context?.reason === "github-source-file")
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new RegistrySourceFileError(registryFile, error, {
|
||||
message: `Failed to read source registry file at ${formatSourcePath(
|
||||
registryFile,
|
||||
options.source
|
||||
)}.`,
|
||||
context: { registryFile, source: options.source },
|
||||
suggestion:
|
||||
registryFile === "registry.json"
|
||||
? "Check that the repository has a registry.json file at its root."
|
||||
: "Check that the included registry.json file exists and that the include 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,
|
||||
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.posix.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.posix.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 = joinSourcePath(registryDir, includePath)
|
||||
validateRegistryFileWithinRoot(resolvedPath)
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
function validateRegistryFileWithinRoot(registryFile: string) {
|
||||
if (!isSourcePathInsideRoot(registryFile)) {
|
||||
throw new RegistryValidationError(
|
||||
`Invalid registry file at ${registryFile}: registry includes must stay inside the source registry root.`,
|
||||
{
|
||||
registryFile,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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.posix.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 = joinSourcePath(registryDir, file.path)
|
||||
if (!isSourcePathInsideRoot(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 getSourceDir(filePath: string) {
|
||||
const dirname = path.posix.dirname(filePath)
|
||||
return dirname === "." ? "." : dirname
|
||||
}
|
||||
|
||||
function joinSourcePath(...segments: string[]) {
|
||||
const normalized = path.posix.normalize(path.posix.join(...segments))
|
||||
return normalized === "." ? "" : normalized
|
||||
}
|
||||
|
||||
function normalizeSourcePath(filePath: string) {
|
||||
const normalized = path.posix.normalize(filePath)
|
||||
return normalized.startsWith("./") ? normalized.slice(2) : normalized
|
||||
}
|
||||
|
||||
function relativeSourcePath(from: string, to: string) {
|
||||
const relative = path.posix.relative(from, to)
|
||||
return relative || path.posix.basename(to)
|
||||
}
|
||||
|
||||
function isSourcePathInsideRoot(filePath: string, root = ".") {
|
||||
const relative = path.posix.relative(root, filePath)
|
||||
return (
|
||||
!!relative && !relative.startsWith("..") && !path.posix.isAbsolute(relative)
|
||||
)
|
||||
}
|
||||
|
||||
function hasParentTraversal(filePath: string) {
|
||||
return filePath.split(/[\\/]+/).includes("..")
|
||||
}
|
||||
|
||||
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 formatSourcePath(registryFile: string, source?: string) {
|
||||
return source ? `${source}/${registryFile}` : registryFile
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isGitHubRegistrySource } from "@/src/registry/address"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
@@ -50,6 +51,10 @@ export function validateRegistryConfigForItems(
|
||||
config?: Config
|
||||
): void {
|
||||
for (const item of items) {
|
||||
if (isGitHubRegistrySource(item)) {
|
||||
continue
|
||||
}
|
||||
|
||||
buildUrlAndHeadersForRegistryItem(item, configWithDefaults(config))
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ npx shadcn@latest docs button dialog select
|
||||
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||
@@ -227,22 +227,26 @@ npx shadcn@latest preset resolve --json
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
npx shadcn@latest add @magicui/shimmer-button
|
||||
npx shadcn@latest add owner/repo/item
|
||||
npx shadcn@latest add --all
|
||||
|
||||
# Preview changes before adding/updating.
|
||||
npx shadcn@latest add button --dry-run
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
npx shadcn@latest add @acme/form --view button.tsx
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# Search registries.
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
# View registry item details (for items not yet installed).
|
||||
npx shadcn@latest view @shadcn/button
|
||||
npx shadcn@latest view owner/repo/item
|
||||
```
|
||||
|
||||
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||
@@ -257,4 +261,5 @@ npx shadcn@latest view @shadcn/button
|
||||
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
|
||||
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||
|
||||
@@ -68,7 +68,8 @@ If no preset is provided, the CLI offers to open the custom preset builder on `u
|
||||
npx shadcn@latest add [components...] [options]
|
||||
```
|
||||
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
|
||||
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
@@ -105,6 +106,9 @@ npx shadcn@latest add button --view button.tsx
|
||||
# Works with URLs too.
|
||||
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||
|
||||
# Works with public GitHub registries too.
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# CSS diffs.
|
||||
npx shadcn@latest add button --diff globals.css
|
||||
```
|
||||
@@ -129,7 +133,9 @@ See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the fu
|
||||
npx shadcn@latest search <registries...> [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||
and registry catalog URLs. Without `-q`, lists all items.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ---------------------- | ------- |
|
||||
@@ -144,7 +150,9 @@ Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Withou
|
||||
npx shadcn@latest view <items...> [options]
|
||||
```
|
||||
|
||||
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
|
||||
Displays item info including file contents. Examples:
|
||||
`npx shadcn@latest view @shadcn/button`,
|
||||
`npx shadcn@latest view owner/repo/item`.
|
||||
|
||||
### `docs` — Get component documentation URLs
|
||||
|
||||
@@ -232,6 +240,9 @@ npx shadcn@latest build [registry] [options]
|
||||
|
||||
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||
|
||||
For authoring rules, `include`, item definitions, `registryDependencies`, and
|
||||
GitHub registry behavior, see [registry.md](./registry.md).
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------- | ----- | ----------------- | ------------ |
|
||||
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# shadcn MCP Server
|
||||
|
||||
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
|
||||
The CLI includes an MCP server that lets AI assistants search, browse, view, and install items from registries.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ shadcn mcp init # write config for your editor
|
||||
Editor config files:
|
||||
|
||||
| Editor | Config file |
|
||||
|--------|------------|
|
||||
| ----------- | ------------------------------- |
|
||||
| Claude Code | `.mcp.json` |
|
||||
| Cursor | `.cursor/mcp.json` |
|
||||
| VS Code | `.vscode/mcp.json` |
|
||||
@@ -35,13 +35,16 @@ Returns registry names from `components.json`. Errors if no `components.json` ex
|
||||
|
||||
### `shadcn:list_items_in_registries`
|
||||
|
||||
Lists all items from one or more registries.
|
||||
Lists all items from one or more registries. Registries can be configured
|
||||
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
||||
registry catalog URLs.
|
||||
|
||||
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
|
||||
|
||||
### `shadcn:search_items_in_registries`
|
||||
|
||||
Fuzzy search across registries.
|
||||
Fuzzy search across registries. Registries can be configured namespaces, public
|
||||
GitHub sources, or registry catalog URLs.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
|
||||
|
||||
@@ -49,7 +52,8 @@ Fuzzy search across registries.
|
||||
|
||||
View item details including full file contents.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
|
||||
**Input:** `items` (string[]) — e.g.
|
||||
`["@shadcn/button", "@shadcn/card", "owner/repo/item"]`
|
||||
|
||||
### `shadcn:get_item_examples_from_registries`
|
||||
|
||||
@@ -73,7 +77,10 @@ Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||
|
||||
## Configuring Registries
|
||||
|
||||
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
|
||||
Namespaced and authenticated registries are set in `components.json`. The
|
||||
`@shadcn` registry is always built-in. Public GitHub registries can also be used
|
||||
directly as `owner/repo` registry sources when the repository has a root
|
||||
`registry.json`; they do not need `components.json` configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
277
skills/shadcn/registry.md
Normal file
277
skills/shadcn/registry.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Registry Authoring and Addresses
|
||||
|
||||
Use this reference when the user wants to create, fix, publish, or reason about
|
||||
a shadcn registry.
|
||||
|
||||
## Mental Model
|
||||
|
||||
A registry has two forms:
|
||||
|
||||
- **Source registry**: an authored `registry.json` in a project or repository.
|
||||
It may use `include` and file paths that point at source files.
|
||||
- **Built registry**: generated JSON files served to CLI consumers, usually
|
||||
from `public/r`. Use `npx shadcn@latest build` to create this form.
|
||||
|
||||
The CLI installer consumes registry item payloads. A source registry is a way to
|
||||
author those payloads from real files.
|
||||
|
||||
Registry items are not limited to React components. They can distribute
|
||||
components, hooks, utilities, design tokens, pages, config files, docs, rules,
|
||||
workflows, templates, MCP files, and other project files.
|
||||
|
||||
## Root `registry.json`
|
||||
|
||||
The root registry file should define registry metadata and either `items` or
|
||||
`include`.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"items": [
|
||||
{
|
||||
"name": "absolute-url",
|
||||
"type": "registry:lib",
|
||||
"title": "Absolute URL",
|
||||
"description": "A utility to turn any path into an absolute URL.",
|
||||
"files": [
|
||||
{
|
||||
"path": "lib/absolute-url.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Root registry rules:
|
||||
|
||||
- Root `registry.json` must include `name` and `homepage`.
|
||||
- `items` is an array of registry item definitions.
|
||||
- `include` may be used to split the source registry into multiple files.
|
||||
- Included registry files may omit `name` and `homepage`.
|
||||
|
||||
## Include
|
||||
|
||||
Use `include` to keep large registries modular.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"include": ["registry/ui/registry.json", "registry/blocks/registry.json"]
|
||||
}
|
||||
```
|
||||
|
||||
Include rules:
|
||||
|
||||
- Include paths are relative to the `registry.json` that declares them.
|
||||
- Include paths must explicitly point to a `registry.json` file.
|
||||
- Do not use remote URLs, absolute paths, or parent traversal (`..`).
|
||||
- Item file paths are relative to the registry file that declares the item.
|
||||
- Duplicate item names fail across the resolved registry.
|
||||
|
||||
Example included file:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"files": [
|
||||
{
|
||||
"path": "button.tsx",
|
||||
"type": "registry:ui"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If this file is at `registry/ui/registry.json`, then `button.tsx` is read from
|
||||
`registry/ui/button.tsx`, and the built item path is emitted relative to the
|
||||
root registry.
|
||||
|
||||
## Item Definitions
|
||||
|
||||
Common item fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:block",
|
||||
"title": "Login Form",
|
||||
"description": "A login form with email and password fields.",
|
||||
"dependencies": ["zod"],
|
||||
"registryDependencies": ["button", "input", "label"],
|
||||
"files": [
|
||||
{
|
||||
"path": "blocks/login-form.tsx",
|
||||
"type": "registry:block"
|
||||
}
|
||||
],
|
||||
"cssVars": {
|
||||
"light": {
|
||||
"brand": "oklch(0.62 0.18 250)"
|
||||
},
|
||||
"dark": {
|
||||
"brand": "oklch(0.72 0.16 250)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Important fields:
|
||||
|
||||
- `name`: the installable item name. It is not necessarily a file path.
|
||||
- `type`: one of the registry item types, such as `registry:ui`,
|
||||
`registry:block`, `registry:lib`, `registry:hook`, `registry:file`,
|
||||
`registry:page`, `registry:theme`, `registry:style`, `registry:font`, or
|
||||
`registry:item`.
|
||||
- `files`: source files copied or generated by the item.
|
||||
- `dependencies`: npm runtime dependencies.
|
||||
- `devDependencies`: npm development dependencies.
|
||||
- `registryDependencies`: other registry items required by this item.
|
||||
- `cssVars`, `css`, `tailwind`, `envVars`, and `docs`: optional install-time
|
||||
additions.
|
||||
|
||||
File rules:
|
||||
|
||||
- File paths are relative to the declaring `registry.json`.
|
||||
- `registry:file` and `registry:page` files require a `target`.
|
||||
- Do not use remote file URLs in source registry file paths.
|
||||
- Keep source files copy-pasteable: no hidden app-only imports.
|
||||
|
||||
## Registry Dependencies
|
||||
|
||||
`registryDependencies` entries are item addresses, not file paths.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:block",
|
||||
"registryDependencies": ["button", "@acme/input", "acme/ui/card#v1.2.0"],
|
||||
"files": [
|
||||
{
|
||||
"path": "blocks/login-form.tsx",
|
||||
"type": "registry:block"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Dependency rules:
|
||||
|
||||
- Bare names such as `"button"` mean official shadcn items.
|
||||
- Bare names never mean same-registry or same-repository items.
|
||||
- Namespaced dependencies use `@namespace/item-name`.
|
||||
- GitHub dependencies use `owner/repo/item-name`.
|
||||
- Pin GitHub dependencies with `owner/repo/item-name#ref` when needed.
|
||||
- Refs are not inherited. If `owner/repo/foo#v2` depends on `bar` from the same
|
||||
repo at `v2`, write `owner/repo/bar#v2`.
|
||||
- Do not use relative dependencies such as `"./bar"`.
|
||||
|
||||
## Address Schemes
|
||||
|
||||
When reasoning about a registry item string, classify it first.
|
||||
|
||||
| Address | Scheme | Meaning |
|
||||
| ----------------------------------- | --------- | ------------------------------------------------------------ |
|
||||
| `button` | shadcn | Official shadcn item named `button`. |
|
||||
| `@acme/button` | namespace | Item `button` from configured registry `@acme`. |
|
||||
| `@acme/ui/button` | namespace | Item `ui/button` from configured registry `@acme`. |
|
||||
| `https://example.com/r/button.json` | url | Built registry item JSON at that URL. |
|
||||
| `./button.json` | file | Built registry item JSON on disk. |
|
||||
| `acme/ui/button` | github | Item `button` from GitHub repo `acme/ui`. |
|
||||
| `acme/ui/forms/login#main` | github | Item `forms/login` from GitHub repo `acme/ui` at ref `main`. |
|
||||
|
||||
For namespace and GitHub addresses, slashful item names are allowed and are item
|
||||
names, not file paths. Addresses ending in `.json` keep file-address
|
||||
precedence, so `acme/ui/data/schema.json` is treated as a file path, not a
|
||||
GitHub item address.
|
||||
|
||||
## GitHub Registries
|
||||
|
||||
A public GitHub repository can act as a source registry when it has a root
|
||||
`registry.json`.
|
||||
|
||||
```txt
|
||||
owner/repo/item-name[#ref]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The first two path segments are GitHub owner and repo.
|
||||
- All remaining path segments are the registry item name.
|
||||
- The source entrypoint is always root `registry.json`.
|
||||
- GitHub registries are source registries consumed directly by the CLI. They do
|
||||
not require `shadcn build` or generated item JSON files.
|
||||
- `include` follows the same source-registry rules as local registries.
|
||||
- Currently, GitHub addresses support public `github.com` repositories only.
|
||||
- Private repos and GitHub Enterprise require explicit product decisions.
|
||||
|
||||
When implementing GitHub registry fetching, resolve refs to a commit SHA before
|
||||
reading source files. Do not read moving refs directly from
|
||||
`raw.githubusercontent.com`, because branch-like refs can be cached for several
|
||||
minutes.
|
||||
|
||||
Preferred flow:
|
||||
|
||||
```txt
|
||||
owner/repo[#ref]
|
||||
-> resolve ref with git ls-remote
|
||||
-> commit SHA
|
||||
-> read https://raw.githubusercontent.com/{owner}/{repo}/{sha}/registry.json
|
||||
-> read includes and item files from the same SHA
|
||||
```
|
||||
|
||||
This keeps a command on one consistent repository snapshot.
|
||||
|
||||
Full 40-character commit SHAs are already stable and can be used directly.
|
||||
Branches, tags, and short refs require Git so the CLI can resolve them to a
|
||||
commit SHA first.
|
||||
|
||||
## Build and Verify
|
||||
|
||||
Use the CLI to build source registries:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build
|
||||
npx shadcn@latest build registry.json --output public/r
|
||||
```
|
||||
|
||||
Use CLI commands to inspect the result:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list @acme
|
||||
npx shadcn@latest search @acme -q "login"
|
||||
npx shadcn@latest view @acme/login-form
|
||||
npx shadcn@latest add @acme/login-form --dry-run
|
||||
npx shadcn@latest registry validate ./registry.json
|
||||
```
|
||||
|
||||
Use GitHub addresses directly for public GitHub registries:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list owner/repo
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest view owner/repo/item
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
npx shadcn@latest registry validate owner/repo
|
||||
```
|
||||
|
||||
When working on registry implementation in the shadcn/ui codebase:
|
||||
|
||||
- Keep address parsing pure and testable.
|
||||
- Do not add side effects to validators.
|
||||
- Preserve existing behavior for official shadcn, namespace, URL, and file
|
||||
schemes.
|
||||
- Add tests for address parsing, source loading, dependency resolution, list,
|
||||
search, view, and add paths.
|
||||
- Prefer small source-reader abstractions over a plugin system until there are
|
||||
multiple real providers.
|
||||
Reference in New Issue
Block a user