mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-21 06:41:32 +00:00
Compare commits
46 Commits
shadcn@3.1
...
shadcn@3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1289192d4f | ||
|
|
75dde2e646 | ||
|
|
b9f3ce1988 | ||
|
|
cdf58be7e1 | ||
|
|
fae1a81add | ||
|
|
fc6d909ba2 | ||
|
|
590b9be610 | ||
|
|
41eb9d5c46 | ||
|
|
b7c28199be | ||
|
|
7869defd42 | ||
|
|
6daa5215cc | ||
|
|
722fb81b95 | ||
|
|
543be31722 | ||
|
|
09b90cd5c2 | ||
|
|
c95959a9b3 | ||
|
|
08820ce5ee | ||
|
|
cb96e58992 | ||
|
|
fce5926265 | ||
|
|
f7c0f81258 | ||
|
|
960b22b301 | ||
|
|
6f057c9cc3 | ||
|
|
615a32d97a | ||
|
|
bfe6e1946c | ||
|
|
baaa82e4e7 | ||
|
|
caeed7bd65 | ||
|
|
61254f0c3f | ||
|
|
3dcd797f2c | ||
|
|
b76f5cdbf7 | ||
|
|
fcb1e2ca50 | ||
|
|
df94537e0f | ||
|
|
275e3a2d59 | ||
|
|
e5402f9a20 | ||
|
|
04668da018 | ||
|
|
0805751703 | ||
|
|
9ecb19cf2e | ||
|
|
9c5eb0d20f | ||
|
|
2752ce11d8 | ||
|
|
d972caa853 | ||
|
|
00b2f0796e | ||
|
|
3ed9af5757 | ||
|
|
a4237e38f7 | ||
|
|
1178d40352 | ||
|
|
cc612359ee | ||
|
|
4d0272a659 | ||
|
|
a15534bdb7 | ||
|
|
62c41c3271 |
54
.github/workflows/validate-registries.yml
vendored
Normal file
54
.github/workflows/validate-registries.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Validate Registries
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm validate:registries
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build --filter=shadcn
|
||||
|
||||
- name: Validate registries
|
||||
run: pnpm --filter=v4 validate:registries
|
||||
@@ -30,9 +30,13 @@ const TOP_LEVEL_SECTIONS = [
|
||||
name: "MCP Server",
|
||||
href: "/docs/mcp",
|
||||
},
|
||||
{
|
||||
name: "Changelog",
|
||||
href: "/docs/changelog",
|
||||
},
|
||||
]
|
||||
const EXCLUDED_SECTIONS = ["installation", "dark-mode"]
|
||||
const EXCLUDED_PAGES = ["/docs"]
|
||||
const EXCLUDED_PAGES = ["/docs", "/docs/changelog"]
|
||||
|
||||
export function DocsSidebar({
|
||||
tree,
|
||||
|
||||
@@ -28,6 +28,10 @@ const TOP_LEVEL_SECTIONS = [
|
||||
name: "MCP Server",
|
||||
href: "/docs/mcp",
|
||||
},
|
||||
{
|
||||
name: "Changelog",
|
||||
href: "/docs/changelog",
|
||||
},
|
||||
]
|
||||
|
||||
export function MobileNav({
|
||||
|
||||
@@ -4,6 +4,24 @@ description: Latest updates and announcements.
|
||||
toc: false
|
||||
---
|
||||
|
||||
## September 2025 - Registry Index
|
||||
|
||||
We've created an index of open source registries that you can install items from.
|
||||
|
||||
You can search, view and add items from the registry index without configuring the `.components.json` file.
|
||||
|
||||
They'll be automatically added to your `components.json` file for you.
|
||||
|
||||
```bash
|
||||
npx shadcn add @ai-elements/prompt-input
|
||||
```
|
||||
|
||||
The full list of registries is available at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json).
|
||||
|
||||
To add a registry to the index, submit a PR to the `shadcn/ui` repository. See the [registry index documentation](/docs/registry/registry-index) for more details.
|
||||
|
||||
---
|
||||
|
||||
## August 2025 - shadcn CLI 3.0 and MCP Server
|
||||
|
||||
We just shipped shadcn CLI 3.0 with support for namespaced registries, advanced authentication, new commands and a completely rewritten registry engine.
|
||||
|
||||
@@ -328,16 +328,153 @@ Add custom theme variables to the `theme` object.
|
||||
}
|
||||
```
|
||||
|
||||
## Add CSS imports
|
||||
|
||||
Use `@import` to add CSS imports to your registry item. The imports will be placed at the top of the CSS file.
|
||||
|
||||
### Basic import
|
||||
|
||||
```json title="example-import.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "custom-import",
|
||||
"type": "registry:component",
|
||||
"css": {
|
||||
"@import \"tailwindcss\"": {},
|
||||
"@import \"./styles/base.css\"": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Import with url() syntax
|
||||
|
||||
```json title="example-url-import.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "font-import",
|
||||
"type": "registry:item",
|
||||
"css": {
|
||||
"@import url(\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap\")": {},
|
||||
"@import url('./local-styles.css')": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Import with media queries
|
||||
|
||||
```json title="example-media-import.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "responsive-import",
|
||||
"type": "registry:item",
|
||||
"css": {
|
||||
"@import \"print-styles.css\" print": {},
|
||||
"@import url(\"mobile.css\") screen and (max-width: 768px)": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Add custom plugins
|
||||
|
||||
Use `@plugin` to add Tailwind plugins to your registry item. Plugins will be automatically placed after imports and before other content.
|
||||
|
||||
**Important:** When using plugins from npm packages, you must also add them to the `dependencies` array.
|
||||
|
||||
### Basic plugin usage
|
||||
|
||||
```json title="example-plugin.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "custom-plugin",
|
||||
"type": "registry:item",
|
||||
"css": {
|
||||
"@plugin \"@tailwindcss/typography\"": {},
|
||||
"@plugin \"foo\"": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin with npm dependency
|
||||
|
||||
When using plugins from npm packages like `@tailwindcss/typography`, include them in the dependencies.
|
||||
|
||||
```json title="example-typography.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "typography-component",
|
||||
"type": "registry:item",
|
||||
"dependencies": ["@tailwindcss/typography"],
|
||||
"css": {
|
||||
"@plugin \"@tailwindcss/typography\"": {},
|
||||
"@layer components": {
|
||||
".prose": {
|
||||
"max-width": "65ch"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scoped and file-based plugins
|
||||
|
||||
```json title="example-scoped-plugin.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "scoped-plugins",
|
||||
"type": "registry:component",
|
||||
"css": {
|
||||
"@plugin @tailwindcss/typography": {},
|
||||
"@plugin foo": {}
|
||||
"@plugin \"@headlessui/tailwindcss\"": {},
|
||||
"@plugin \"tailwindcss/plugin\"": {},
|
||||
"@plugin \"./custom-plugin.js\"": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple plugins with automatic ordering
|
||||
|
||||
When you add multiple plugins, they are automatically grouped together and deduplicated.
|
||||
|
||||
```json title="example-multiple-plugins.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "multiple-plugins",
|
||||
"type": "registry:item",
|
||||
"dependencies": [
|
||||
"@tailwindcss/typography",
|
||||
"@tailwindcss/forms",
|
||||
"tw-animate-css"
|
||||
],
|
||||
"css": {
|
||||
"@plugin \"@tailwindcss/typography\"": {},
|
||||
"@plugin \"@tailwindcss/forms\"": {},
|
||||
"@plugin \"tw-animate-css\"": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Combined imports and plugins
|
||||
|
||||
When using both `@import` and `@plugin` directives, imports are placed first, followed by plugins, then other CSS content.
|
||||
|
||||
```json title="example-combined.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "combined-example",
|
||||
"type": "registry:item",
|
||||
"dependencies": ["@tailwindcss/typography", "tw-animate-css"],
|
||||
"css": {
|
||||
"@import \"tailwindcss\"": {},
|
||||
"@import url(\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap\")": {},
|
||||
"@plugin \"@tailwindcss/typography\"": {},
|
||||
"@plugin \"tw-animate-css\"": {},
|
||||
"@layer base": {
|
||||
"body": {
|
||||
"font-family": "Inter, sans-serif"
|
||||
}
|
||||
},
|
||||
"@utility content-auto": {
|
||||
"content-visibility": "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"authentication",
|
||||
"examples",
|
||||
"mcp",
|
||||
"registry-index",
|
||||
"open-in-v0",
|
||||
"registry-json",
|
||||
"registry-item-json"
|
||||
|
||||
@@ -41,7 +41,7 @@ Registry namespaces are prefixed with `@` and provide a way to organize and refe
|
||||
|
||||
## Decentralized Namespace System
|
||||
|
||||
We intentionally designed the namespace system to be decentralized. There is no central registrar for namespaces. You are free to create and use any namespace you want.
|
||||
We intentionally designed the namespace system to be decentralized. There is a [central open source registry index](/docs/registry/registry-index) for open source namespaces but you are free to create and use any namespace you want.
|
||||
|
||||
This decentralized approach gives you complete flexibility to organize your resources however makes sense for your organization.
|
||||
|
||||
@@ -615,82 +615,6 @@ Resolution order:
|
||||
2. **Circular Dependency Prevention**: Automatically detects and prevents circular dependencies
|
||||
3. **Smart Installation Order**: Dependencies are installed first, then the resources that use them
|
||||
|
||||
We intentionally designed the namespace system to be decentralized. There is no central registrar for namespaces. You are free to create and use any namespace you want.
|
||||
|
||||
This decentralized approach gives you complete flexibility to organize your resources however makes sense for your organization.
|
||||
|
||||
You can create multiple registries for different purposes:
|
||||
|
||||
```json title="components.json" showLineNumbers
|
||||
{
|
||||
"registries": {
|
||||
"@acme-ui": "https://registry.acme.com/ui/{name}.json",
|
||||
"@acme-docs": "https://registry.acme.com/docs/{name}.json",
|
||||
"@acme-ai": "https://registry.acme.com/ai/{name}.json",
|
||||
"@acme-themes": "https://registry.acme.com/themes/{name}.json",
|
||||
"@acme-internal": {
|
||||
"url": "https://internal.acme.com/registry/{name}.json",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INTERNAL_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to:
|
||||
|
||||
- **Organize by type**: Separate UI components, documentation, AI resources, etc.
|
||||
- **Organize by team**: Different teams can maintain their own registries
|
||||
- **Organize by visibility**: Public vs. private resources
|
||||
- **Organize by version**: Stable vs. experimental registries
|
||||
- **No naming conflicts**: Since there's no central authority, you don't need to worry about namespace collisions
|
||||
|
||||
### Examples of Multi-Registry Setups
|
||||
|
||||
#### By Resource Type
|
||||
|
||||
```json title="components.json" showLineNumbers
|
||||
{
|
||||
"@components": "https://cdn.company.com/components/{name}.json",
|
||||
"@hooks": "https://cdn.company.com/hooks/{name}.json",
|
||||
"@utils": "https://cdn.company.com/utils/{name}.json",
|
||||
"@prompts": "https://cdn.company.com/ai-prompts/{name}.json"
|
||||
}
|
||||
```
|
||||
|
||||
#### By Team or Department
|
||||
|
||||
```json
|
||||
{
|
||||
"@design": "https://design.company.com/registry/{name}.json",
|
||||
"@engineering": "https://eng.company.com/registry/{name}.json",
|
||||
"@marketing": "https://marketing.company.com/registry/{name}.json"
|
||||
}
|
||||
```
|
||||
|
||||
#### By Stability
|
||||
|
||||
```json title="components.json" showLineNumbers
|
||||
{
|
||||
"@stable": "https://registry.company.com/stable/{name}.json",
|
||||
"@latest": "https://registry.company.com/beta/{name}.json",
|
||||
"@experimental": "https://registry.company.com/experimental/{name}.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Built-in Registries
|
||||
|
||||
The `@shadcn` namespace is built-in and always available:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @shadcn/button
|
||||
```
|
||||
|
||||
This is equivalent to installing from the default shadcn/ui registry.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
79
apps/v4/content/docs/registry/registry-index.mdx
Normal file
79
apps/v4/content/docs/registry/registry-index.mdx
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Index
|
||||
description: Open Source Registry Index
|
||||
---
|
||||
|
||||
The open source registry index is a list of all the open source registries that are available to use out of the box.
|
||||
|
||||
When you run `shadcn add` or `shadcn search`, the CLI will automatically check the registry index for the registry you are looking for and add it to your `components.json` file.
|
||||
|
||||
You can see the full list at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json).
|
||||
|
||||
## Adding a Registry
|
||||
|
||||
You can submit a PR to add a registry to the index by adding it to the [registries.json](https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/registries.json) file.
|
||||
|
||||
Here's an example of how to add a registry to the index:
|
||||
|
||||
```json title="registries.json" showLineNumbers
|
||||
{
|
||||
"@acme": "https://registry.acme.com/r/{name}.json",
|
||||
"@example": "https://example.com/r/{name}"
|
||||
}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
1. The registry must be open source and publicly accessible.
|
||||
2. The registry must be a valid JSON file that conforms to the [registry schema specification](/docs/registry/registry-json).
|
||||
3. The registry is expected to be a flat registry with no nested items i.e `/registry.json` and `/component-name.json` files are expected to be in the root of the registry.
|
||||
4. The `files` array, if present, must NOT include a `content` property.
|
||||
|
||||
Here's an example of a valid registry:
|
||||
|
||||
```json title="registry.json" showLineNumbers
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"items": [
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:component",
|
||||
"title": "Login Form",
|
||||
"description": "A login form component.",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/auth/login-form.tsx",
|
||||
"type": "registry:component"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "example-login-form",
|
||||
"type": "registry:component",
|
||||
"title": "Example Login Form",
|
||||
"description": "An example showing how to use the login form component.",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/examples/example-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
At the root of the `shadcn/ui` project, you can run the following command to validate the `registries.json` file.
|
||||
|
||||
```bash
|
||||
pnpm validate:registries
|
||||
```
|
||||
|
||||
This will validate the registries.json file and output any errors.
|
||||
|
||||
Once you have submitted your PR, it will be validated and reviewed by the team.
|
||||
@@ -14,6 +14,7 @@
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"registry:build": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,json,mdx}\" --cache",
|
||||
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
|
||||
"validate:registries": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/validate-registries.mts",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -86,7 +87,7 @@
|
||||
"recharts": "2.15.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"shadcn": "3.1.0",
|
||||
"shadcn": "3.3.1",
|
||||
"shiki": "^1.10.1",
|
||||
"sonner": "^2.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
|
||||
36
apps/v4/public/r/registries.json
Normal file
36
apps/v4/public/r/registries.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
|
||||
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json",
|
||||
"@alexcarpenter": "https://ui.alexcarpenter.me/r/{name}.json",
|
||||
"@alpine": "https://alpine-registry.vercel.app/r/{name}.json",
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json",
|
||||
"@blocks": "https://blocks.so/r/{name}.json",
|
||||
"@clerk": "https://clerk.com/r/{name}.json",
|
||||
"@cult-ui": "https://cult-ui.com/r/{name}.json",
|
||||
"@kibo-ui": "https://www.kibo-ui.com/r/{name}.json",
|
||||
"@kokonutui": "https://kokonutui.com/r/{name}.json",
|
||||
"@magicui": "https://magicui.design/r/{name}.json",
|
||||
"@motion-primitives": "https://motion-primitives.com/c/{name}.json",
|
||||
"@originui": "https://originui.com/r/{name}.json",
|
||||
"@prompt-kit": "https://prompt-kit.com/c/{name}.json",
|
||||
"@tailark": "https://tailark.com/r/{name}.json",
|
||||
"@react-bits": "https://reactbits.dev/r/{name}.json",
|
||||
"@reui": "https://reui.io/r/{name}.json",
|
||||
"@heseui": "https://www.heseui.com/r/{name}.json",
|
||||
"@paceui-ui": "https://ui.paceui.com/r/{name}.json",
|
||||
"@basecn": "https://basecn.dev/r/{name}.json",
|
||||
"@ncdai": "https://chanhdai.com/r/{name}.json",
|
||||
"@8bitcn": "https://8bitcn.com/r/{name}.json",
|
||||
"@billingsdk": "https://billingsdk.com/r/{name}.json",
|
||||
"@elements": "https://tryelements.dev/r/{name}.json",
|
||||
"@nativeui": "https://nativeui.io/registry/{name}.json",
|
||||
"@smoothui": "https://smoothui.dev/r/{name}.json",
|
||||
"@formcn": "https://formcn.dev/r/{name}.json",
|
||||
"@limeplay": "https://limeplay.winoffrg.dev/r/{name}.json",
|
||||
"@skiper-ui": "https://skiper-ui.com/registry/{name}.json",
|
||||
"@shadcn-editor": "https://shadcn-editor.vercel.app/r/{name}.json",
|
||||
"@rigidui": "https://rigidui.com/r/{name}.json",
|
||||
"@retroui": "https://retroui.dev/r/{name}.json",
|
||||
"@wds": "https://wds-shadcn-registry.netlify.app/r/{name}.json",
|
||||
"@97cn": "https://97cn.itzik.co/r/{name}.json"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -179,70 +179,72 @@ function ChartTooltipContent({
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -275,31 +277,33 @@ function ChartLegendContent({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
58
apps/v4/scripts/validate-registries.mts
Normal file
58
apps/v4/scripts/validate-registries.mts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { registrySchema } from "shadcn/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
const registriesIndexSchema = z.record(
|
||||
z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/),
|
||||
z.string().refine((url) => url.includes("{name}"))
|
||||
)
|
||||
|
||||
async function main() {
|
||||
// 1. Validate the registries.json file.
|
||||
const registriesFile = path.join(process.cwd(), "public/r/registries.json")
|
||||
const content = await fs.readFile(registriesFile, "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
const registries = registriesIndexSchema.parse(data)
|
||||
|
||||
// 2. Validate each registry endpoint.
|
||||
const errors: string[] = []
|
||||
for (const [name, url] of Object.entries(registries)) {
|
||||
try {
|
||||
const testUrl = url.replace("{name}", "registry")
|
||||
const response = await fetch(testUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
errors.push(`${name}: HTTP ${response.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
registrySchema.parse(json)
|
||||
console.log(`✅ ${name}`)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
errors.push(`${name}: ${error.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
errors.push(
|
||||
`${name}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("\n❌ Validation failed:")
|
||||
errors.forEach((err) => console.error(` ${err}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("\n✅ All registries passed validation.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("❌ Error:", error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -88,7 +88,7 @@
|
||||
"react-resizable-panels": "^2.0.22",
|
||||
"react-wrap-balancer": "^0.4.1",
|
||||
"recharts": "2.12.7",
|
||||
"shadcn": "3.1.0",
|
||||
"shadcn": "3.3.1",
|
||||
"sharp": "^0.32.6",
|
||||
"sonner": "^1.2.3",
|
||||
"swr": "2.2.6-beta.3",
|
||||
|
||||
@@ -185,70 +185,72 @@ const ChartTooltipContent = React.forwardRef<
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -285,31 +287,33 @@ const ChartLegendContent = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,70 +185,72 @@ const ChartTooltipContent = React.forwardRef<
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -285,31 +287,33 @@ const ChartLegendContent = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"pub:beta": "cd packages/shadcn && pnpm pub:beta",
|
||||
"pub:release": "cd packages/shadcn && pnpm pub:release",
|
||||
"test:dev": "turbo run test --filter=!shadcn-ui --force",
|
||||
"test": "start-server-and-test v4:dev http://localhost:4000 test:dev"
|
||||
"test": "start-server-and-test v4:dev http://localhost:4000 test:dev",
|
||||
"validate:registries": "pnpm --filter=v4 validate:registries"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @shadcn/ui
|
||||
|
||||
## 3.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [`75dde2e64679081dad63ecd20f3fd9e3b68fa0ce`](https://github.com/shadcn-ui/ui/commit/75dde2e64679081dad63ecd20f3fd9e3b68fa0ce) Thanks [@shadcn](https://github.com/shadcn)! - fix deps in cts projects
|
||||
|
||||
## 3.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8216](https://github.com/shadcn-ui/ui/pull/8216) [`fc6d909ba23ac1ba09cf32087f0524aca398b5aa`](https://github.com/shadcn-ui/ui/commit/fc6d909ba23ac1ba09cf32087f0524aca398b5aa) Thanks [@shadcn](https://github.com/shadcn)! - add getRegistriesIndex
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8186](https://github.com/shadcn-ui/ui/pull/8186) [`cdf58be7e1ed25bf1dd19a1c60612c5e89b82a60`](https://github.com/shadcn-ui/ui/commit/cdf58be7e1ed25bf1dd19a1c60612c5e89b82a60) Thanks [@imskyleen](https://github.com/imskyleen)! - fix transformCssVars function with prefix
|
||||
|
||||
- [#8036](https://github.com/shadcn-ui/ui/pull/8036) [`fae1a81addb22429c103d5d08813e1c80779d5fb`](https://github.com/shadcn-ui/ui/commit/fae1a81addb22429c103d5d08813e1c80779d5fb) Thanks [@fuma-nama](https://github.com/fuma-nama)! - fix async imports not being transformed when installing components
|
||||
|
||||
## 3.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8147](https://github.com/shadcn-ui/ui/pull/8147) [`e5402f9a20f070e92e7384c1ae08e6bfb79cd7a9`](https://github.com/shadcn-ui/ui/commit/e5402f9a20f070e92e7384c1ae08e6bfb79cd7a9) Thanks [@shadcn](https://github.com/shadcn)! - fix recursive namespacing
|
||||
|
||||
## 3.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8128](https://github.com/shadcn-ui/ui/pull/8128) [`9c5eb0d20f0b75b28dccee219bf74fc9cd2019c6`](https://github.com/shadcn-ui/ui/commit/9c5eb0d20f0b75b28dccee219bf74fc9cd2019c6) Thanks [@shadcn](https://github.com/shadcn)! - add support for registries index
|
||||
|
||||
## 3.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shadcn",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"description": "Add components to your apps.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -68,8 +68,10 @@
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-transform-typescript": "^7.28.0",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@modelcontextprotocol/sdk": "^1.17.2",
|
||||
"browserslist": "^4.26.2",
|
||||
"commander": "^14.0.0",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"dedent": "^1.6.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { updateAppIndex } from "@/src/utils/update-app-index"
|
||||
import { Command } from "commander"
|
||||
import prompts from "prompts"
|
||||
@@ -76,6 +77,17 @@ export const add = new Command()
|
||||
})
|
||||
}
|
||||
|
||||
let hasNewRegistries = false
|
||||
if (components.length > 0) {
|
||||
const { config: updatedConfig, newRegistries } =
|
||||
await ensureRegistriesInConfig(components, initialConfig, {
|
||||
silent: options.silent,
|
||||
writeFile: false,
|
||||
})
|
||||
initialConfig = updatedConfig
|
||||
hasNewRegistries = newRegistries.length > 0
|
||||
}
|
||||
|
||||
if (components.length > 0) {
|
||||
const [registryItem] = await getRegistryItems([components[0]], {
|
||||
config: initialConfig,
|
||||
@@ -134,6 +146,7 @@ export const add = new Command()
|
||||
let { errors, config } = await preFlightAdd(options)
|
||||
|
||||
// No components.json file. Prompt the user to run init.
|
||||
let initHasRun = false
|
||||
if (errors[ERRORS.MISSING_CONFIG]) {
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
@@ -155,15 +168,18 @@ export const add = new Command()
|
||||
force: true,
|
||||
defaults: false,
|
||||
skipPreflight: false,
|
||||
silent: true,
|
||||
silent: options.silent || !hasNewRegistries,
|
||||
isNewProject: false,
|
||||
srcDir: options.srcDir,
|
||||
cssVariables: options.cssVariables,
|
||||
baseStyle: true,
|
||||
components: options.components,
|
||||
})
|
||||
initHasRun = true
|
||||
}
|
||||
|
||||
let shouldUpdateAppIndex = false
|
||||
|
||||
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
|
||||
const { projectPath, template } = await createProject({
|
||||
cwd: options.cwd,
|
||||
@@ -187,12 +203,14 @@ export const add = new Command()
|
||||
force: true,
|
||||
defaults: false,
|
||||
skipPreflight: true,
|
||||
silent: true,
|
||||
silent: !hasNewRegistries && options.silent,
|
||||
isNewProject: true,
|
||||
srcDir: options.srcDir,
|
||||
cssVariables: options.cssVariables,
|
||||
baseStyle: true,
|
||||
components: options.components,
|
||||
})
|
||||
initHasRun = true
|
||||
|
||||
shouldUpdateAppIndex =
|
||||
options.components?.length === 1 &&
|
||||
@@ -206,7 +224,18 @@ export const add = new Command()
|
||||
)
|
||||
}
|
||||
|
||||
await addComponents(options.components, config, options)
|
||||
const { config: updatedConfig } = await ensureRegistriesInConfig(
|
||||
options.components,
|
||||
config,
|
||||
{
|
||||
silent: options.silent || hasNewRegistries,
|
||||
}
|
||||
)
|
||||
config = updatedConfig
|
||||
|
||||
if (!initHasRun) {
|
||||
await addComponents(options.components, config, options)
|
||||
}
|
||||
|
||||
// If we're adding a single component and it's from the v0 registry,
|
||||
// let's update the app/page.tsx file to import the component.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/src/registry/api"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { BASE_COLORS } from "@/src/registry/constants"
|
||||
import { BASE_COLORS, BUILTIN_REGISTRIES } from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { rawConfigSchema } from "@/src/schema"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { updateTailwindContent } from "@/src/utils/updaters/update-tailwind-content"
|
||||
import { Command } from "commander"
|
||||
@@ -175,6 +176,16 @@ export const init = new Command()
|
||||
createFileBackup(componentsJsonPath)
|
||||
}
|
||||
|
||||
// Ensure all registries used in components are configured.
|
||||
const { config: updatedConfig } = await ensureRegistriesInConfig(
|
||||
components,
|
||||
shadowConfig,
|
||||
{
|
||||
silent: true,
|
||||
}
|
||||
)
|
||||
shadowConfig = updatedConfig
|
||||
|
||||
// This forces a shadowConfig validation early in the process.
|
||||
buildUrlAndHeadersForRegistryItem(components[0], shadowConfig)
|
||||
|
||||
@@ -266,6 +277,31 @@ export async function runInit(
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the list of components to be added.
|
||||
const components = [
|
||||
// "index" is the default shadcn style.
|
||||
// Why index? Because when style is true, we read style from components.json and fetch that.
|
||||
// i.e new-york from components.json then fetch /styles/new-york/index.
|
||||
// TODO: Fix this so that we can extend any style i.e --style=new-york.
|
||||
...(options.baseStyle ? ["index"] : []),
|
||||
...(options.components ?? []),
|
||||
]
|
||||
|
||||
// Ensure registries are configured for the components we're about to add.
|
||||
const fullConfigForRegistry = await resolveConfigPaths(options.cwd, config)
|
||||
const { config: configWithRegistries } = await ensureRegistriesInConfig(
|
||||
components,
|
||||
fullConfigForRegistry,
|
||||
{
|
||||
silent: true,
|
||||
}
|
||||
)
|
||||
|
||||
// Update config with any new registries found.
|
||||
if (configWithRegistries.registries) {
|
||||
config.registries = configWithRegistries.registries
|
||||
}
|
||||
|
||||
const componentSpinner = spinner(`Writing components.json.`).start()
|
||||
const targetPath = path.resolve(options.cwd, "components.json")
|
||||
const backupPath = `${targetPath}${FILE_BACKUP_SUFFIX}`
|
||||
@@ -279,20 +315,20 @@ export async function runInit(
|
||||
config = { ...merged, registries }
|
||||
}
|
||||
|
||||
// Make sure to filter out built-in registries.
|
||||
// TODO: fix this in ensureRegistriesInConfig.
|
||||
config.registries = Object.fromEntries(
|
||||
Object.entries(config.registries || {}).filter(
|
||||
([key]) => !Object.keys(BUILTIN_REGISTRIES).includes(key)
|
||||
)
|
||||
)
|
||||
|
||||
// Write components.json.
|
||||
await fs.writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf8")
|
||||
componentSpinner.succeed()
|
||||
|
||||
// Add components.
|
||||
const fullConfig = await resolveConfigPaths(options.cwd, config)
|
||||
const components = [
|
||||
// "index" is the default shadcn style.
|
||||
// Why index? Because when style is true, we read style from components.json and fetch that.
|
||||
// i.e new-york from components.json then fetch /styles/new-york/index.
|
||||
// TODO: Fix this so that we can extend any style i.e --style=new-york.
|
||||
...(options.baseStyle ? ["index"] : []),
|
||||
...(options.components ?? []),
|
||||
]
|
||||
await addComponents(components, fullConfig, {
|
||||
// Init will always overwrite files.
|
||||
overwrite: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { rawConfigSchema } from "@/src/schema"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { Command } from "commander"
|
||||
import fsExtra from "fs-extra"
|
||||
import { z } from "zod"
|
||||
@@ -84,6 +85,19 @@ export const search = new Command()
|
||||
// Use shadow config if getConfig fails (partial components.json).
|
||||
}
|
||||
|
||||
const { config: updatedConfig, newRegistries } =
|
||||
await ensureRegistriesInConfig(
|
||||
registries.map((registry) => `${registry}/registry`),
|
||||
config,
|
||||
{
|
||||
silent: true,
|
||||
writeFile: false,
|
||||
}
|
||||
)
|
||||
if (newRegistries.length > 0) {
|
||||
config.registries = updatedConfig.registries
|
||||
}
|
||||
|
||||
// Validate registries early for better error messages.
|
||||
validateRegistryConfigForItems(registries, config)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { rawConfigSchema } from "@/src/schema"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { Command } from "commander"
|
||||
import fsExtra from "fs-extra"
|
||||
import { z } from "zod"
|
||||
@@ -54,6 +55,15 @@ export const view = new Command()
|
||||
// Use shadow config if getConfig fails (partial components.json).
|
||||
}
|
||||
|
||||
const { config: updatedConfig, newRegistries } =
|
||||
await ensureRegistriesInConfig(items, config, {
|
||||
silent: true,
|
||||
writeFile: false,
|
||||
})
|
||||
if (newRegistries.length > 0) {
|
||||
config.registries = updatedConfig.registries
|
||||
}
|
||||
|
||||
// Validate registries early for better error messages.
|
||||
validateRegistryConfigForItems(items, config)
|
||||
|
||||
|
||||
@@ -27,7 +27,13 @@ import {
|
||||
} from "vitest"
|
||||
import { z } from "zod"
|
||||
|
||||
import { getRegistriesConfig, getRegistry, getRegistryItems } from "./api"
|
||||
import {
|
||||
getRegistriesConfig,
|
||||
getRegistriesIndex,
|
||||
getRegistry,
|
||||
getRegistryItems,
|
||||
} from "./api"
|
||||
import { RegistriesIndexParseError } from "./errors"
|
||||
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn(),
|
||||
@@ -96,6 +102,13 @@ const server = setupServer(
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
http.get(`${REGISTRY_URL}/registries.json`, () => {
|
||||
return HttpResponse.json({
|
||||
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
|
||||
"@example": "https://example.com/registry/styles/{style}/{name}.json",
|
||||
"@test": "https://test.com/registry/{name}.json",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1650,4 +1663,75 @@ describe("getRegistriesConfig", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRegistriesIndex", () => {
|
||||
it("should fetch and parse the registries index successfully", async () => {
|
||||
const result = await getRegistriesIndex()
|
||||
|
||||
expect(result).toEqual({
|
||||
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
|
||||
"@example": "https://example.com/registry/styles/{style}/{name}.json",
|
||||
"@test": "https://test.com/registry/{name}.json",
|
||||
})
|
||||
})
|
||||
|
||||
it("should respect cache options", async () => {
|
||||
// Test with cache disabled
|
||||
const result1 = await getRegistriesIndex({ useCache: false })
|
||||
expect(result1).toBeDefined()
|
||||
|
||||
// Test with cache enabled
|
||||
const result2 = await getRegistriesIndex({ useCache: true })
|
||||
expect(result2).toBeDefined()
|
||||
|
||||
// Results should be the same
|
||||
expect(result1).toEqual(result2)
|
||||
})
|
||||
|
||||
it("should use default cache behavior when no options provided", async () => {
|
||||
const result = await getRegistriesIndex()
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result).toBe("object")
|
||||
})
|
||||
|
||||
it("should handle network errors properly", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/registries.json`, () => {
|
||||
return new HttpResponse(null, { status: 500 })
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getRegistriesIndex({ useCache: false })).rejects.toThrow()
|
||||
|
||||
try {
|
||||
await getRegistriesIndex({ useCache: false })
|
||||
} catch (error) {
|
||||
expect(error).not.toBeInstanceOf(RegistriesIndexParseError)
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle invalid JSON response", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/registries.json`, () => {
|
||||
return HttpResponse.json({
|
||||
"invalid-namespace": "some-url",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getRegistriesIndex({ useCache: false })).rejects.toThrow(
|
||||
RegistriesIndexParseError
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle network timeout", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/registries.json`, () => {
|
||||
return HttpResponse.error()
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getRegistriesIndex({ useCache: false })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import path from "path"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { BASE_COLORS, BUILTIN_REGISTRIES } from "@/src/registry/constants"
|
||||
import {
|
||||
BASE_COLORS,
|
||||
BUILTIN_REGISTRIES,
|
||||
REGISTRY_URL,
|
||||
} from "@/src/registry/constants"
|
||||
import {
|
||||
clearRegistryContext,
|
||||
setRegistryHeaders,
|
||||
} from "@/src/registry/context"
|
||||
import {
|
||||
ConfigParseError,
|
||||
RegistriesIndexParseError,
|
||||
RegistryInvalidNamespaceError,
|
||||
RegistryNotFoundError,
|
||||
RegistryParseError,
|
||||
@@ -20,7 +25,7 @@ import {
|
||||
import { isUrl } from "@/src/registry/utils"
|
||||
import {
|
||||
iconsSchema,
|
||||
rawConfigSchema,
|
||||
registriesIndexSchema,
|
||||
registryBaseColorSchema,
|
||||
registryConfigSchema,
|
||||
registryIndexSchema,
|
||||
@@ -272,3 +277,25 @@ export async function getItemTargetPath(
|
||||
type
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRegistriesIndex(options?: { useCache?: boolean }) {
|
||||
options = {
|
||||
useCache: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
const url = `${REGISTRY_URL}/registries.json`
|
||||
const [data] = await fetchRegistry([url], {
|
||||
useCache: options.useCache,
|
||||
})
|
||||
|
||||
try {
|
||||
return registriesIndexSchema.parse(data)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new RegistriesIndexParseError(error)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,3 +285,41 @@ export class ConfigParseError extends RegistryError {
|
||||
this.name = "ConfigParseError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistriesIndexParseError extends RegistryError {
|
||||
public readonly parseError: unknown
|
||||
|
||||
constructor(parseError: unknown) {
|
||||
let message = "Failed to parse registries index"
|
||||
|
||||
if (parseError instanceof z.ZodError) {
|
||||
const invalidNamespaces = parseError.errors
|
||||
.filter((e) => e.path.length > 0)
|
||||
.map((e) => `"${e.path[0]}"`)
|
||||
.filter((v, i, arr) => arr.indexOf(v) === i) // remove duplicates
|
||||
|
||||
if (invalidNamespaces.length > 0) {
|
||||
message = `Failed to parse registries index. Invalid registry namespace(s): ${invalidNamespaces.join(
|
||||
", "
|
||||
)}\n${parseError.errors
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n")}`
|
||||
} else {
|
||||
message = `Failed to parse registries index:\n${parseError.errors
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n")}`
|
||||
}
|
||||
}
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.PARSE_ERROR,
|
||||
cause: parseError,
|
||||
context: { parseError },
|
||||
suggestion:
|
||||
"The registries index may be corrupted or have invalid registry namespace format. Registry names must start with @ (e.g., @shadcn, @example).",
|
||||
})
|
||||
|
||||
this.parseError = parseError
|
||||
this.name = "RegistriesIndexParseError"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { getRegistryItems, resolveRegistryItems, getRegistry } from "./api"
|
||||
export {
|
||||
getRegistryItems,
|
||||
resolveRegistryItems,
|
||||
getRegistry,
|
||||
getRegistriesIndex,
|
||||
} from "./api"
|
||||
|
||||
export { searchRegistries } from "./search"
|
||||
|
||||
@@ -11,6 +16,7 @@ export {
|
||||
RegistryNotConfiguredError,
|
||||
RegistryLocalFileError,
|
||||
RegistryParseError,
|
||||
RegistriesIndexParseError,
|
||||
RegistryMissingEnvironmentVariablesError,
|
||||
RegistryInvalidNamespaceError,
|
||||
} from "./errors"
|
||||
|
||||
469
packages/shadcn/src/registry/namespaces.test.ts
Normal file
469
packages/shadcn/src/registry/namespaces.test.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { Config } from "../utils/get-config"
|
||||
import { BUILTIN_REGISTRIES } from "./constants"
|
||||
import { RegistryNotConfiguredError } from "./errors"
|
||||
import { resolveRegistryNamespaces } from "./namespaces"
|
||||
import * as resolver from "./resolver"
|
||||
|
||||
// Mock the resolver module.
|
||||
vi.mock("./resolver", () => ({
|
||||
fetchRegistryItems: vi.fn(),
|
||||
}))
|
||||
|
||||
// Test utility function to check namespace configuration.
|
||||
function checkNamespaceConfiguration(
|
||||
namespaces: string[],
|
||||
config: Config
|
||||
): { configured: string[]; missing: string[] } {
|
||||
const configured: string[] = []
|
||||
const missing: string[] = []
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
if (BUILTIN_REGISTRIES[namespace] || config.registries?.[namespace]) {
|
||||
configured.push(namespace)
|
||||
} else {
|
||||
missing.push(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
return { configured, missing }
|
||||
}
|
||||
|
||||
describe("resolveRegistryNamespaces", () => {
|
||||
const mockConfig: Config = {
|
||||
style: "default",
|
||||
tailwind: {
|
||||
config: "tailwind.config.js",
|
||||
css: "app/globals.css",
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd: "/test",
|
||||
tailwindConfig: "/test/tailwind.config.js",
|
||||
tailwindCss: "/test/app/globals.css",
|
||||
utils: "/test/lib/utils",
|
||||
components: "/test/components",
|
||||
ui: "/test/components/ui",
|
||||
lib: "/test/lib",
|
||||
hooks: "/test/hooks",
|
||||
},
|
||||
registries: {
|
||||
...BUILTIN_REGISTRIES,
|
||||
"@foo": "https://foo.com/registry/{name}",
|
||||
"@bar": "https://bar.com/registry/{name}",
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should discover namespaces from direct components", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems.mockResolvedValue([
|
||||
{ name: "button", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@foo/button", "@bar/card"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toEqual(["@foo", "@bar"])
|
||||
})
|
||||
|
||||
it("should skip built-in registries", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems.mockResolvedValue([
|
||||
{ name: "button", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@shadcn/button", "@foo/card"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toEqual(["@foo"])
|
||||
})
|
||||
|
||||
it("should discover namespaces from registry dependencies", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "dialog",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@bar/button", "@baz/modal"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "button", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "modal", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@foo/dialog"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toContain("@foo")
|
||||
expect(namespaces).toContain("@bar")
|
||||
expect(namespaces).toContain("@baz")
|
||||
})
|
||||
|
||||
it("should handle circular dependencies", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "comp-a",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@bar/comp-b"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "comp-b",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@foo/comp-a"],
|
||||
},
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@foo/comp-a"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toEqual(["@foo", "@bar"])
|
||||
// Should only fetch each component once despite circular reference.
|
||||
expect(mockFetchRegistryItems).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should handle RegistryNotConfiguredError gracefully", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems.mockRejectedValue(
|
||||
new RegistryNotConfiguredError("@unknown")
|
||||
)
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@unknown/button"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toEqual(["@unknown"])
|
||||
})
|
||||
|
||||
it("should continue processing on other errors", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "card", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@foo/button", "@bar/card"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
// Should still discover both @foo and @bar.
|
||||
// @foo from the initial parse, @bar from successful fetch.
|
||||
expect(namespaces).toContain("@foo")
|
||||
expect(namespaces).toContain("@bar")
|
||||
expect(namespaces).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle deeply nested dependencies", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "level-1",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@level2/component"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "component",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@level3/deep"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "deep",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
},
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@level1/level-1"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toEqual(["@level1", "@level2", "@level3"])
|
||||
})
|
||||
|
||||
it("should return unique namespaces", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "comp-a",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@foo/shared", "@bar/shared"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "shared", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "shared", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["@foo/comp-a", "@foo/comp-b", "@bar/comp-c"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
// Should not have duplicate @foo.
|
||||
expect(namespaces).toEqual(["@foo", "@bar"])
|
||||
})
|
||||
|
||||
it("should handle components without namespace", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems.mockResolvedValue([
|
||||
{ name: "button", type: "registry:ui", files: [], dependencies: [] },
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["button", "@foo/card"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toEqual(["@foo"])
|
||||
})
|
||||
|
||||
it("should handle empty input", async () => {
|
||||
const namespaces = await resolveRegistryNamespaces([], mockConfig)
|
||||
|
||||
expect(namespaces).toEqual([])
|
||||
expect(resolver.fetchRegistryItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should discover namespaces from components without namespaces but with registryDependencies", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "my-component",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@foo/dep1", "@bar/dep2"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "dep1",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "dep2",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
},
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["button"], // Component without namespace
|
||||
mockConfig
|
||||
)
|
||||
|
||||
// Should discover namespaces from registryDependencies even though "button" has no namespace.
|
||||
expect(namespaces).toEqual(["@foo", "@bar"])
|
||||
})
|
||||
|
||||
it("should discover namespaces from URL components with registryDependencies", async () => {
|
||||
const mockFetchRegistryItems = vi.mocked(resolver.fetchRegistryItems)
|
||||
mockFetchRegistryItems
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "to-8bitcn",
|
||||
type: "registry:item",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
registryDependencies: ["@8bitcn/button"],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "branch",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
dependencies: [],
|
||||
},
|
||||
])
|
||||
|
||||
const namespaces = await resolveRegistryNamespaces(
|
||||
["https://api.npoint.io/2e006917dca7f7367495", "@ai-elements/branch"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(namespaces).toContain("@8bitcn")
|
||||
expect(namespaces).toContain("@ai-elements")
|
||||
|
||||
// Verify fetchRegistryItems was called with the correct arguments.
|
||||
expect(mockFetchRegistryItems).toHaveBeenCalledWith(
|
||||
["https://api.npoint.io/2e006917dca7f7367495"],
|
||||
mockConfig,
|
||||
{ useCache: true }
|
||||
)
|
||||
expect(mockFetchRegistryItems).toHaveBeenCalledWith(
|
||||
["@ai-elements/branch"],
|
||||
mockConfig,
|
||||
{ useCache: true }
|
||||
)
|
||||
expect(mockFetchRegistryItems).toHaveBeenCalledWith(
|
||||
["@8bitcn/button"],
|
||||
mockConfig,
|
||||
{ useCache: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkNamespaceConfiguration", () => {
|
||||
const mockConfig: Config = {
|
||||
style: "default",
|
||||
tailwind: {
|
||||
config: "tailwind.config.js",
|
||||
css: "app/globals.css",
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd: "/test",
|
||||
tailwindConfig: "/test/tailwind.config.js",
|
||||
tailwindCss: "/test/app/globals.css",
|
||||
utils: "/test/lib/utils",
|
||||
components: "/test/components",
|
||||
ui: "/test/components/ui",
|
||||
lib: "/test/lib",
|
||||
hooks: "/test/hooks",
|
||||
},
|
||||
registries: {
|
||||
...BUILTIN_REGISTRIES,
|
||||
"@foo": "https://foo.com/registry/{name}",
|
||||
"@bar": "https://bar.com/registry/{name}",
|
||||
},
|
||||
}
|
||||
|
||||
it("should identify configured namespaces", () => {
|
||||
const result = checkNamespaceConfiguration(["@foo", "@bar"], mockConfig)
|
||||
|
||||
expect(result.configured).toEqual(["@foo", "@bar"])
|
||||
expect(result.missing).toEqual([])
|
||||
})
|
||||
|
||||
it("should identify missing namespaces", () => {
|
||||
const result = checkNamespaceConfiguration(
|
||||
["@foo", "@unknown", "@missing"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(result.configured).toEqual(["@foo"])
|
||||
expect(result.missing).toEqual(["@unknown", "@missing"])
|
||||
})
|
||||
|
||||
it("should handle built-in registries as configured", () => {
|
||||
const result = checkNamespaceConfiguration(["@shadcn", "@foo"], mockConfig)
|
||||
|
||||
expect(result.configured).toEqual(["@shadcn", "@foo"])
|
||||
expect(result.missing).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle empty input", () => {
|
||||
const result = checkNamespaceConfiguration([], mockConfig)
|
||||
|
||||
expect(result.configured).toEqual([])
|
||||
expect(result.missing).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle config without registries", () => {
|
||||
const configWithoutRegistries: Config = {
|
||||
...mockConfig,
|
||||
registries: undefined,
|
||||
}
|
||||
|
||||
const result = checkNamespaceConfiguration(
|
||||
["@foo", "@bar"],
|
||||
configWithoutRegistries
|
||||
)
|
||||
|
||||
expect(result.configured).toEqual([])
|
||||
expect(result.missing).toEqual(["@foo", "@bar"])
|
||||
})
|
||||
|
||||
it("should handle mixed configured and missing namespaces", () => {
|
||||
const result = checkNamespaceConfiguration(
|
||||
["@shadcn", "@foo", "@unknown", "@bar", "@missing"],
|
||||
mockConfig
|
||||
)
|
||||
|
||||
expect(result.configured).toContain("@shadcn")
|
||||
expect(result.configured).toContain("@foo")
|
||||
expect(result.configured).toContain("@bar")
|
||||
expect(result.missing).toContain("@unknown")
|
||||
expect(result.missing).toContain("@missing")
|
||||
})
|
||||
})
|
||||
63
packages/shadcn/src/registry/namespaces.ts
Normal file
63
packages/shadcn/src/registry/namespaces.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
|
||||
import { RegistryNotConfiguredError } from "@/src/registry/errors"
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import { fetchRegistryItems } from "@/src/registry/resolver"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
|
||||
// Recursively discovers all registry namespaces including nested ones.
|
||||
export async function resolveRegistryNamespaces(
|
||||
components: string[],
|
||||
config: Config
|
||||
) {
|
||||
const discoveredNamespaces = new Set<string>()
|
||||
const visitedItems = new Set<string>()
|
||||
const itemsToProcess = [...components]
|
||||
|
||||
while (itemsToProcess.length > 0) {
|
||||
const currentItem = itemsToProcess.shift()!
|
||||
|
||||
if (visitedItems.has(currentItem)) {
|
||||
continue
|
||||
}
|
||||
visitedItems.add(currentItem)
|
||||
|
||||
const { registry } = parseRegistryAndItemFromString(currentItem)
|
||||
if (registry && !BUILTIN_REGISTRIES[registry]) {
|
||||
discoveredNamespaces.add(registry)
|
||||
}
|
||||
|
||||
try {
|
||||
const [item] = await fetchRegistryItems([currentItem], config, {
|
||||
useCache: true,
|
||||
})
|
||||
|
||||
if (item?.registryDependencies) {
|
||||
for (const dep of item.registryDependencies) {
|
||||
const { registry: depRegistry } = parseRegistryAndItemFromString(dep)
|
||||
if (depRegistry && !BUILTIN_REGISTRIES[depRegistry]) {
|
||||
discoveredNamespaces.add(depRegistry)
|
||||
}
|
||||
|
||||
if (!visitedItems.has(dep)) {
|
||||
itemsToProcess.push(dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If a registry is not configured, we still track it.
|
||||
if (error instanceof RegistryNotConfiguredError) {
|
||||
const { registry } = parseRegistryAndItemFromString(currentItem)
|
||||
if (registry && !BUILTIN_REGISTRIES[registry]) {
|
||||
discoveredNamespaces.add(registry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// For other errors (network, parsing, etc.), we skip this item
|
||||
// but continue processing others to discover as many namespaces as possible.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(discoveredNamespaces)
|
||||
}
|
||||
@@ -426,7 +426,16 @@ describe("resolveRegistryItems with URL dependencies", () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: { cwd: process.cwd() },
|
||||
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
|
||||
|
||||
const result = await resolveRegistryTree([tempFile], mockConfig)
|
||||
@@ -489,7 +498,16 @@ describe("resolveRegistryItems with URL dependencies", () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: { cwd: process.cwd() },
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
tailwindCss: "./globals.css",
|
||||
utils: "./lib/utils",
|
||||
components: "./components",
|
||||
lib: "./lib",
|
||||
hooks: "./hooks",
|
||||
ui: "./components/ui",
|
||||
},
|
||||
registries: {
|
||||
"@custom": {
|
||||
url: "https://custom-registry.com/{name}.json",
|
||||
|
||||
@@ -117,239 +117,228 @@ export async function resolveRegistryTree(
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
) {
|
||||
try {
|
||||
options = {
|
||||
useCache: true,
|
||||
...options,
|
||||
options = {
|
||||
useCache: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
let allDependencyItems: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
let allDependencyRegistryNames: string[] = []
|
||||
|
||||
const uniqueNames = Array.from(new Set(names))
|
||||
|
||||
const results = await fetchRegistryItems(uniqueNames, config, options)
|
||||
|
||||
const resultMap = new Map<string, z.infer<typeof registryItemSchema>>()
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i]) {
|
||||
resultMap.set(uniqueNames[i], results[i])
|
||||
}
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
let allDependencyItems: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
let allDependencyRegistryNames: string[] = []
|
||||
|
||||
const uniqueNames = Array.from(new Set(names))
|
||||
|
||||
const results = await fetchRegistryItems(uniqueNames, config, options)
|
||||
|
||||
const resultMap = new Map<string, z.infer<typeof registryItemSchema>>()
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i]) {
|
||||
resultMap.set(uniqueNames[i], results[i])
|
||||
}
|
||||
for (const [sourceName, item] of Array.from(resultMap.entries())) {
|
||||
// Add source tracking
|
||||
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
|
||||
...item,
|
||||
_source: sourceName,
|
||||
}
|
||||
payload.push(itemWithSource)
|
||||
|
||||
for (const [sourceName, item] of Array.from(resultMap.entries())) {
|
||||
// Add source tracking
|
||||
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
|
||||
...item,
|
||||
_source: sourceName,
|
||||
}
|
||||
payload.push(itemWithSource)
|
||||
if (item.registryDependencies) {
|
||||
// Resolve namespace syntax and set headers for dependencies
|
||||
let resolvedDependencies = item.registryDependencies
|
||||
|
||||
if (item.registryDependencies) {
|
||||
// Resolve namespace syntax and set headers for dependencies
|
||||
let resolvedDependencies = item.registryDependencies
|
||||
|
||||
// Check for namespaced dependencies when no registries are configured
|
||||
if (!config?.registries) {
|
||||
const namespacedDeps = item.registryDependencies.filter(
|
||||
(dep: string) => dep.startsWith("@")
|
||||
)
|
||||
if (namespacedDeps.length > 0) {
|
||||
const { registry } = parseRegistryAndItemFromString(
|
||||
namespacedDeps[0]
|
||||
)
|
||||
throw new RegistryNotConfiguredError(registry)
|
||||
}
|
||||
} else {
|
||||
resolvedDependencies = resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
const { items, registryNames } = await resolveDependenciesRecursively(
|
||||
resolvedDependencies,
|
||||
config,
|
||||
options,
|
||||
new Set(uniqueNames)
|
||||
// Check for namespaced dependencies when no registries are configured
|
||||
if (!config?.registries) {
|
||||
const namespacedDeps = item.registryDependencies.filter((dep: string) =>
|
||||
dep.startsWith("@")
|
||||
)
|
||||
allDependencyItems.push(...items)
|
||||
allDependencyRegistryNames.push(...registryNames)
|
||||
}
|
||||
}
|
||||
|
||||
payload.push(...allDependencyItems)
|
||||
|
||||
// Handle any remaining registry names that need index resolution
|
||||
if (allDependencyRegistryNames.length > 0) {
|
||||
// Remove duplicates from registry names
|
||||
const uniqueRegistryNames = Array.from(
|
||||
new Set(allDependencyRegistryNames)
|
||||
)
|
||||
|
||||
// Separate namespaced and non-namespaced items
|
||||
const nonNamespacedItems = uniqueRegistryNames.filter(
|
||||
(name) => !name.startsWith("@")
|
||||
)
|
||||
const namespacedDepItems = uniqueRegistryNames.filter((name) =>
|
||||
name.startsWith("@")
|
||||
)
|
||||
|
||||
// Handle namespaced dependency items
|
||||
if (namespacedDepItems.length > 0) {
|
||||
// This will now throw specific errors on failure
|
||||
const depResults = await fetchRegistryItems(
|
||||
namespacedDepItems,
|
||||
config,
|
||||
options
|
||||
if (namespacedDeps.length > 0) {
|
||||
const { registry } = parseRegistryAndItemFromString(namespacedDeps[0])
|
||||
throw new RegistryNotConfiguredError(registry)
|
||||
}
|
||||
} else {
|
||||
resolvedDependencies = resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
|
||||
for (let i = 0; i < depResults.length; i++) {
|
||||
const item = depResults[i]
|
||||
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
|
||||
...item,
|
||||
_source: namespacedDepItems[i],
|
||||
}
|
||||
payload.push(itemWithSource)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-namespaced items, we need the index and style resolution
|
||||
if (nonNamespacedItems.length > 0) {
|
||||
const index = await getShadcnRegistryIndex()
|
||||
if (!index && payload.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (index) {
|
||||
// If we're resolving the index, we want it to go first
|
||||
if (nonNamespacedItems.includes("index")) {
|
||||
nonNamespacedItems.unshift("index")
|
||||
}
|
||||
|
||||
// Resolve non-namespaced items through the existing flow
|
||||
// Get URLs for all registry items including their dependencies
|
||||
const registryUrls: string[] = []
|
||||
for (const name of nonNamespacedItems) {
|
||||
const itemDependencies = await resolveRegistryDependencies(
|
||||
name,
|
||||
config,
|
||||
options
|
||||
)
|
||||
registryUrls.push(...itemDependencies)
|
||||
}
|
||||
|
||||
// Deduplicate URLs
|
||||
const uniqueUrls = Array.from(new Set(registryUrls))
|
||||
let result = await fetchRegistry(uniqueUrls, options)
|
||||
const registryPayload = z.array(registryItemSchema).parse(result)
|
||||
payload.push(...registryPayload)
|
||||
}
|
||||
}
|
||||
const { items, registryNames } = await resolveDependenciesRecursively(
|
||||
resolvedDependencies,
|
||||
config,
|
||||
options,
|
||||
new Set(uniqueNames)
|
||||
)
|
||||
allDependencyItems.push(...items)
|
||||
allDependencyRegistryNames.push(...registryNames)
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.length) {
|
||||
return null
|
||||
}
|
||||
payload.push(...allDependencyItems)
|
||||
|
||||
// No deduplication - we want to support multiple items with the same name from different sources
|
||||
// Handle any remaining registry names that need index resolution
|
||||
if (allDependencyRegistryNames.length > 0) {
|
||||
// Remove duplicates from registry names
|
||||
const uniqueRegistryNames = Array.from(new Set(allDependencyRegistryNames))
|
||||
|
||||
// If we're resolving the index, we want to fetch
|
||||
// the theme item if a base color is provided.
|
||||
// We do this for index only.
|
||||
// Other components will ship with their theme tokens.
|
||||
if (
|
||||
uniqueNames.includes("index") ||
|
||||
allDependencyRegistryNames.includes("index")
|
||||
) {
|
||||
if (config.tailwind.baseColor) {
|
||||
const theme = await registryGetTheme(config.tailwind.baseColor, config)
|
||||
if (theme) {
|
||||
payload.unshift(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build source map for topological sort
|
||||
const sourceMap = new Map<z.infer<typeof registryItemSchema>, string>()
|
||||
payload.forEach((item) => {
|
||||
// Use the _source property if it was added, otherwise use the name
|
||||
const source = item._source || item.name
|
||||
sourceMap.set(item, source)
|
||||
})
|
||||
|
||||
// Apply topological sort to ensure dependencies come before dependents
|
||||
payload = topologicalSortRegistryItems(payload, sourceMap)
|
||||
|
||||
// Sort the payload so that registry:theme items come first,
|
||||
// while maintaining the relative order of all items.
|
||||
payload.sort((a, b) => {
|
||||
if (a.type === "registry:theme" && b.type !== "registry:theme") {
|
||||
return -1
|
||||
}
|
||||
if (a.type !== "registry:theme" && b.type === "registry:theme") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
let tailwind = {}
|
||||
payload.forEach((item) => {
|
||||
tailwind = deepmerge(tailwind, item.tailwind ?? {})
|
||||
})
|
||||
|
||||
let cssVars = {}
|
||||
payload.forEach((item) => {
|
||||
cssVars = deepmerge(cssVars, item.cssVars ?? {})
|
||||
})
|
||||
|
||||
let css = {}
|
||||
payload.forEach((item) => {
|
||||
css = deepmerge(css, item.css ?? {})
|
||||
})
|
||||
|
||||
let docs = ""
|
||||
payload.forEach((item) => {
|
||||
if (item.docs) {
|
||||
docs += `${item.docs}\n`
|
||||
}
|
||||
})
|
||||
|
||||
let envVars = {}
|
||||
payload.forEach((item) => {
|
||||
envVars = deepmerge(envVars, item.envVars ?? {})
|
||||
})
|
||||
|
||||
// Deduplicate files based on resolved target paths.
|
||||
const deduplicatedFiles = await deduplicateFilesByTarget(
|
||||
payload.map((item) => item.files ?? []),
|
||||
config
|
||||
// Separate namespaced and non-namespaced items
|
||||
const nonNamespacedItems = uniqueRegistryNames.filter(
|
||||
(name) => !name.startsWith("@")
|
||||
)
|
||||
const namespacedDepItems = uniqueRegistryNames.filter((name) =>
|
||||
name.startsWith("@")
|
||||
)
|
||||
|
||||
const parsed = registryResolvedItemsTreeSchema.parse({
|
||||
dependencies: deepmerge.all(
|
||||
payload.map((item) => item.dependencies ?? [])
|
||||
),
|
||||
devDependencies: deepmerge.all(
|
||||
payload.map((item) => item.devDependencies ?? [])
|
||||
),
|
||||
files: deduplicatedFiles,
|
||||
tailwind,
|
||||
cssVars,
|
||||
css,
|
||||
docs,
|
||||
})
|
||||
// Handle namespaced dependency items
|
||||
if (namespacedDepItems.length > 0) {
|
||||
// This will now throw specific errors on failure
|
||||
const depResults = await fetchRegistryItems(
|
||||
namespacedDepItems,
|
||||
config,
|
||||
options
|
||||
)
|
||||
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
parsed.envVars = envVars
|
||||
for (let i = 0; i < depResults.length; i++) {
|
||||
const item = depResults[i]
|
||||
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
|
||||
...item,
|
||||
_source: namespacedDepItems[i],
|
||||
}
|
||||
payload.push(itemWithSource)
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
// For non-namespaced items, we need the index and style resolution
|
||||
if (nonNamespacedItems.length > 0) {
|
||||
const index = await getShadcnRegistryIndex()
|
||||
if (!index && payload.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (index) {
|
||||
// If we're resolving the index, we want it to go first
|
||||
if (nonNamespacedItems.includes("index")) {
|
||||
nonNamespacedItems.unshift("index")
|
||||
}
|
||||
|
||||
// Resolve non-namespaced items through the existing flow
|
||||
// Get URLs for all registry items including their dependencies
|
||||
const registryUrls: string[] = []
|
||||
for (const name of nonNamespacedItems) {
|
||||
const itemDependencies = await resolveRegistryDependencies(
|
||||
name,
|
||||
config,
|
||||
options
|
||||
)
|
||||
registryUrls.push(...itemDependencies)
|
||||
}
|
||||
|
||||
// Deduplicate URLs
|
||||
const uniqueUrls = Array.from(new Set(registryUrls))
|
||||
let result = await fetchRegistry(uniqueUrls, options)
|
||||
const registryPayload = z.array(registryItemSchema).parse(result)
|
||||
payload.push(...registryPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// No deduplication - we want to support multiple items with the same name from different sources
|
||||
|
||||
// If we're resolving the index, we want to fetch
|
||||
// the theme item if a base color is provided.
|
||||
// We do this for index only.
|
||||
// Other components will ship with their theme tokens.
|
||||
if (
|
||||
uniqueNames.includes("index") ||
|
||||
allDependencyRegistryNames.includes("index")
|
||||
) {
|
||||
if (config.tailwind.baseColor) {
|
||||
const theme = await registryGetTheme(config.tailwind.baseColor, config)
|
||||
if (theme) {
|
||||
payload.unshift(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build source map for topological sort
|
||||
const sourceMap = new Map<z.infer<typeof registryItemSchema>, string>()
|
||||
payload.forEach((item) => {
|
||||
// Use the _source property if it was added, otherwise use the name
|
||||
const source = item._source || item.name
|
||||
sourceMap.set(item, source)
|
||||
})
|
||||
|
||||
// Apply topological sort to ensure dependencies come before dependents
|
||||
payload = topologicalSortRegistryItems(payload, sourceMap)
|
||||
|
||||
// Sort the payload so that registry:theme items come first,
|
||||
// while maintaining the relative order of all items.
|
||||
payload.sort((a, b) => {
|
||||
if (a.type === "registry:theme" && b.type !== "registry:theme") {
|
||||
return -1
|
||||
}
|
||||
if (a.type !== "registry:theme" && b.type === "registry:theme") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
let tailwind = {}
|
||||
payload.forEach((item) => {
|
||||
tailwind = deepmerge(tailwind, item.tailwind ?? {})
|
||||
})
|
||||
|
||||
let cssVars = {}
|
||||
payload.forEach((item) => {
|
||||
cssVars = deepmerge(cssVars, item.cssVars ?? {})
|
||||
})
|
||||
|
||||
let css = {}
|
||||
payload.forEach((item) => {
|
||||
css = deepmerge(css, item.css ?? {})
|
||||
})
|
||||
|
||||
let docs = ""
|
||||
payload.forEach((item) => {
|
||||
if (item.docs) {
|
||||
docs += `${item.docs}\n`
|
||||
}
|
||||
})
|
||||
|
||||
let envVars = {}
|
||||
payload.forEach((item) => {
|
||||
envVars = deepmerge(envVars, item.envVars ?? {})
|
||||
})
|
||||
|
||||
// Deduplicate files based on resolved target paths.
|
||||
const deduplicatedFiles = await deduplicateFilesByTarget(
|
||||
payload.map((item) => item.files ?? []),
|
||||
config
|
||||
)
|
||||
|
||||
const parsed = registryResolvedItemsTreeSchema.parse({
|
||||
dependencies: deepmerge.all(payload.map((item) => item.dependencies ?? [])),
|
||||
devDependencies: deepmerge.all(
|
||||
payload.map((item) => item.devDependencies ?? [])
|
||||
),
|
||||
files: deduplicatedFiles,
|
||||
tailwind,
|
||||
cssVars,
|
||||
css,
|
||||
docs,
|
||||
})
|
||||
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
parsed.envVars = envVars
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function resolveDependenciesRecursively(
|
||||
|
||||
@@ -209,3 +209,8 @@ export const searchResultsSchema = z.object({
|
||||
}),
|
||||
items: z.array(searchResultItemSchema),
|
||||
})
|
||||
|
||||
export const registriesIndexSchema = z.record(
|
||||
z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/),
|
||||
z.string()
|
||||
)
|
||||
|
||||
@@ -274,6 +274,10 @@ export function createConfig(partial?: DeepPartial<Config>): Config {
|
||||
...defaultConfig.aliases,
|
||||
...(partial.aliases || {}),
|
||||
},
|
||||
registries: {
|
||||
...defaultConfig.registries,
|
||||
...(partial.registries || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
packages/shadcn/src/utils/registries.ts
Normal file
101
packages/shadcn/src/utils/registries.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import path from "path"
|
||||
import { getRegistriesIndex } from "@/src/registry/api"
|
||||
import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
|
||||
import { resolveRegistryNamespaces } from "@/src/registry/namespaces"
|
||||
import { rawConfigSchema } from "@/src/registry/schema"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import fs from "fs-extra"
|
||||
|
||||
export async function ensureRegistriesInConfig(
|
||||
components: string[],
|
||||
config: Config,
|
||||
options: {
|
||||
silent?: boolean
|
||||
writeFile?: boolean
|
||||
} = {}
|
||||
) {
|
||||
options = {
|
||||
silent: false,
|
||||
writeFile: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
// Use resolveRegistryNamespaces to discover all namespaces including dependencies.
|
||||
const registryNames = await resolveRegistryNamespaces(components, config)
|
||||
|
||||
const missingRegistries = registryNames.filter(
|
||||
(registry) =>
|
||||
!config.registries?.[registry] &&
|
||||
!Object.keys(BUILTIN_REGISTRIES).includes(registry)
|
||||
)
|
||||
|
||||
if (missingRegistries.length === 0) {
|
||||
return {
|
||||
config,
|
||||
newRegistries: [],
|
||||
}
|
||||
}
|
||||
|
||||
// We'll fail silently if we can't fetch the registry index.
|
||||
// The error handling by caller will guide user to add the missing registries.
|
||||
const registryIndex = await getRegistriesIndex({
|
||||
useCache: process.env.NODE_ENV !== "development",
|
||||
})
|
||||
|
||||
if (!registryIndex) {
|
||||
return {
|
||||
config,
|
||||
newRegistries: [],
|
||||
}
|
||||
}
|
||||
|
||||
const foundRegistries: Record<string, string> = {}
|
||||
for (const registry of missingRegistries) {
|
||||
if (registryIndex[registry]) {
|
||||
foundRegistries[registry] = registryIndex[registry]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(foundRegistries).length === 0) {
|
||||
return {
|
||||
config,
|
||||
newRegistries: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out built-in registries from existing config before merging
|
||||
const existingRegistries = Object.fromEntries(
|
||||
Object.entries(config.registries || {}).filter(
|
||||
([key]) => !Object.keys(BUILTIN_REGISTRIES).includes(key)
|
||||
)
|
||||
)
|
||||
|
||||
const newConfigWithRegistries = {
|
||||
...config,
|
||||
registries: {
|
||||
...existingRegistries,
|
||||
...foundRegistries,
|
||||
},
|
||||
}
|
||||
|
||||
if (options.writeFile) {
|
||||
const { resolvedPaths, ...configWithoutResolvedPaths } =
|
||||
newConfigWithRegistries
|
||||
const configSpinner = spinner("Updating components.json.", {
|
||||
silent: options.silent,
|
||||
}).start()
|
||||
const updatedConfig = rawConfigSchema.parse(configWithoutResolvedPaths)
|
||||
await fs.writeFile(
|
||||
path.resolve(config.resolvedPaths.cwd, "components.json"),
|
||||
JSON.stringify(updatedConfig, null, 2) + "\n",
|
||||
"utf-8"
|
||||
)
|
||||
configSpinner.succeed()
|
||||
}
|
||||
|
||||
return {
|
||||
config: newConfigWithRegistries,
|
||||
newRegistries: Object.keys(foundRegistries),
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,10 @@ export const transformCssVars: Transformer = async ({
|
||||
// }
|
||||
// }
|
||||
sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral).forEach((node) => {
|
||||
const value = node.getText()
|
||||
if (value) {
|
||||
const valueWithColorMapping = applyColorMapping(
|
||||
value.replace(/"/g, ""),
|
||||
baseColor.inlineColors
|
||||
)
|
||||
node.replaceWithText(`"${valueWithColorMapping.trim()}"`)
|
||||
const raw = node.getLiteralText()
|
||||
const mapped = applyColorMapping(raw, baseColor.inlineColors).trim()
|
||||
if (mapped !== raw) {
|
||||
node.setLiteralValue(mapped)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { Transformer } from "@/src/utils/transformers"
|
||||
import { SyntaxKind } from "ts-morph"
|
||||
|
||||
export const transformImport: Transformer = async ({
|
||||
sourceFile,
|
||||
@@ -9,32 +10,34 @@ export const transformImport: Transformer = async ({
|
||||
const workspaceAlias = config.aliases?.utils?.split("/")[0]?.slice(1)
|
||||
const utilsImport = `@${workspaceAlias}/lib/utils`
|
||||
|
||||
const importDeclarations = sourceFile.getImportDeclarations()
|
||||
|
||||
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
|
||||
return sourceFile
|
||||
}
|
||||
|
||||
for (const importDeclaration of importDeclarations) {
|
||||
const moduleSpecifier = updateImportAliases(
|
||||
importDeclaration.getModuleSpecifierValue(),
|
||||
for (const specifier of sourceFile.getImportStringLiterals()) {
|
||||
const updated = updateImportAliases(
|
||||
specifier.getLiteralValue(),
|
||||
config,
|
||||
isRemote
|
||||
)
|
||||
|
||||
importDeclaration.setModuleSpecifier(moduleSpecifier)
|
||||
specifier.setLiteralValue(updated)
|
||||
|
||||
// Replace `import { cn } from "@/lib/utils"`
|
||||
if (utilsImport === moduleSpecifier || moduleSpecifier === "@/lib/utils") {
|
||||
const namedImports = importDeclaration.getNamedImports()
|
||||
const cnImport = namedImports.find((i) => i.getName() === "cn")
|
||||
if (cnImport) {
|
||||
importDeclaration.setModuleSpecifier(
|
||||
utilsImport === moduleSpecifier
|
||||
? moduleSpecifier.replace(utilsImport, config.aliases.utils)
|
||||
: config.aliases.utils
|
||||
)
|
||||
}
|
||||
if (utilsImport === updated || updated === "@/lib/utils") {
|
||||
const importDeclaration = specifier.getFirstAncestorByKind(
|
||||
SyntaxKind.ImportDeclaration
|
||||
)
|
||||
const isCnImport = importDeclaration
|
||||
?.getNamedImports()
|
||||
.some((namedImport) => namedImport.getName() === "cn")
|
||||
|
||||
if (!isCnImport) continue
|
||||
|
||||
specifier.setLiteralValue(
|
||||
utilsImport === updated
|
||||
? updated.replace(utilsImport, config.aliases.utils)
|
||||
: config.aliases.utils
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ exports[`transform css vars 2`] = `
|
||||
"import * as React from "react"
|
||||
export function Foo() {
|
||||
return <div className="bg-white hover:bg-stone-100 text-stone-50 sm:focus:text-stone-900 dark:bg-stone-950 dark:hover:bg-stone-800 dark:text-stone-900 dark:sm:focus:text-stone-50">foo</div>
|
||||
}""
|
||||
}"
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -20,7 +20,7 @@ exports[`transform css vars 3`] = `
|
||||
"import * as React from "react"
|
||||
export function Foo() {
|
||||
return <div className={cn("bg-white hover:bg-stone-100 dark:bg-stone-950 dark:hover:bg-stone-800", true && "text-stone-50 sm:focus:text-stone-900 dark:text-stone-900 dark:sm:focus:text-stone-50")}>foo</div>
|
||||
}""
|
||||
}"
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -28,6 +28,6 @@ exports[`transform css vars 4`] = `
|
||||
"import * as React from "react"
|
||||
export function Foo() {
|
||||
return <div className={cn("bg-white border border-stone-200 dark:bg-stone-950 dark:border-stone-800")}>foo</div>
|
||||
}""
|
||||
}"
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`transform async/dynamic imports 1`] = `
|
||||
"import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
async function loadComponent() {
|
||||
const { cn } = await import("@/lib/utils")
|
||||
const module = await import("@/components/ui/card")
|
||||
return module
|
||||
}
|
||||
|
||||
function lazyLoad() {
|
||||
return import("@/components/ui/dialog").then(module => module)
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`transform async/dynamic imports 2`] = `
|
||||
"import { Button } from "~/components/ui/button"
|
||||
|
||||
async function loadUtils() {
|
||||
const utils = await import("~/lib/utils")
|
||||
const { cn } = await import("~/lib/utils")
|
||||
return { utils, cn }
|
||||
}
|
||||
|
||||
const dialogPromise = import("~/components/ui/dialog")
|
||||
const cardModule = import("~/components/ui/card")
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`transform dynamic imports with cn utility 1`] = `
|
||||
"async function loadCn() {
|
||||
const { cn } = await import("@/lib/utils")
|
||||
return cn
|
||||
}
|
||||
|
||||
async function loadMultiple() {
|
||||
const utils1 = await import("@/lib/utils")
|
||||
const { cn, twMerge } = await import("@/lib/utils")
|
||||
const other = await import("@/lib/other")
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`transform dynamic imports with cn utility 2`] = `
|
||||
"async function loadWorkspaceCn() {
|
||||
const { cn } = await import("@workspace/lib/utils")
|
||||
return cn
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`transform import 1`] = `
|
||||
"import * as React from "react"
|
||||
import { Foo } from "bar"
|
||||
@@ -91,3 +143,14 @@ import { Foo } from "bar"
|
||||
import { cn } from "@repo/ui/lib/utils"
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`transform re-exports with dynamic imports 1`] = `
|
||||
"export { cn } from "@/lib/utils"
|
||||
export { Button } from "@/components/ui/button"
|
||||
|
||||
async function load() {
|
||||
const module = await import("@/components/ui/card")
|
||||
return module
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Foo() {
|
||||
exports[`transform tailwind prefix 4`] = `
|
||||
"import * as React from "react"
|
||||
export function Foo() {
|
||||
return <div className={cn("tw:bg-background hover:tw:bg-muted", true && "tw:text-primary-foreground sm:focus:tw:text-accent-foreground")}>foo</div>
|
||||
return <div className={cn("tw:bg-white hover:tw:bg-stone-100 dark:tw:bg-stone-950 dark:hover:tw:bg-stone-800", true && "tw:text-stone-50 sm:focus:tw:text-stone-900 dark:tw:text-stone-900 dark:sm:focus:tw:text-stone-50")}>foo</div>
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -144,7 +144,6 @@ import { Foo } from "bar"
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
|
||||
test("transform import for monorepo", async () => {
|
||||
expect(
|
||||
await transform({
|
||||
@@ -196,3 +195,122 @@ import { Foo } from "bar"
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("transform async/dynamic imports", async () => {
|
||||
expect(
|
||||
await transform({
|
||||
filename: "test.ts",
|
||||
raw: `import * as React from "react"
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
|
||||
async function loadComponent() {
|
||||
const { cn } = await import("@/lib/utils")
|
||||
const module = await import("@/registry/new-york/ui/card")
|
||||
return module
|
||||
}
|
||||
|
||||
function lazyLoad() {
|
||||
return import("@/registry/new-york/ui/dialog").then(module => module)
|
||||
}
|
||||
`,
|
||||
config: {
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
|
||||
expect(
|
||||
await transform({
|
||||
filename: "test.ts",
|
||||
raw: `import { Button } from "@/registry/new-york/ui/button"
|
||||
|
||||
async function loadUtils() {
|
||||
const utils = await import("@/lib/utils")
|
||||
const { cn } = await import("@/lib/utils")
|
||||
return { utils, cn }
|
||||
}
|
||||
|
||||
const dialogPromise = import("@/registry/new-york/ui/dialog")
|
||||
const cardModule = import("@/registry/new-york/ui/card")
|
||||
`,
|
||||
config: {
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "~/components",
|
||||
utils: "~/lib/utils",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("transform dynamic imports with cn utility", async () => {
|
||||
expect(
|
||||
await transform({
|
||||
filename: "test.ts",
|
||||
raw: `async function loadCn() {
|
||||
const { cn } = await import("@/lib/utils")
|
||||
return cn
|
||||
}
|
||||
|
||||
async function loadMultiple() {
|
||||
const utils1 = await import("@/lib/utils")
|
||||
const { cn, twMerge } = await import("@/lib/utils")
|
||||
const other = await import("@/lib/other")
|
||||
}
|
||||
`,
|
||||
config: {
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
|
||||
expect(
|
||||
await transform({
|
||||
filename: "test.ts",
|
||||
raw: `async function loadWorkspaceCn() {
|
||||
const { cn } = await import("@/lib/utils")
|
||||
return cn
|
||||
}
|
||||
`,
|
||||
config: {
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "@workspace/ui/components",
|
||||
utils: "@workspace/ui/lib/utils",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("transform re-exports with dynamic imports", async () => {
|
||||
expect(
|
||||
await transform({
|
||||
filename: "test.ts",
|
||||
raw: `export { cn } from "@/lib/utils"
|
||||
export { Button } from "@/registry/new-york/ui/button"
|
||||
|
||||
async function load() {
|
||||
const module = await import("@/registry/new-york/ui/card")
|
||||
return module
|
||||
}
|
||||
`,
|
||||
config: {
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"**/fixtures/**",
|
||||
"**/templates/**",
|
||||
],
|
||||
testTimeout: 8000,
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
|
||||
@@ -556,9 +556,13 @@ describe("registries", () => {
|
||||
"@two": "http://localhost:5555/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["add", "@two/one", "@one/foo"])
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"add",
|
||||
"@two/one",
|
||||
"@foobar/foo",
|
||||
])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@one"')
|
||||
expect(output.stdout).toContain('Unknown registry "@foobar"')
|
||||
})
|
||||
|
||||
it("should show an error when authentication is not configured", async () => {
|
||||
|
||||
@@ -13,7 +13,18 @@ export async function createRegistryServer(
|
||||
}
|
||||
) {
|
||||
const server = createServer((request, response) => {
|
||||
const urlWithoutQuery = request.url?.split("?")[0]?.replace(/\.json$/, "")
|
||||
const urlRaw = request.url?.split("?")[0]
|
||||
|
||||
// Handle registries.json endpoint (don't strip .json for this one)
|
||||
if (urlRaw?.endsWith("/registries.json")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
// Return empty registry index for tests - we want to test manual configuration.
|
||||
response.end(JSON.stringify({}))
|
||||
return
|
||||
}
|
||||
|
||||
// For other endpoints, strip .json extension
|
||||
const urlWithoutQuery = urlRaw?.replace(/\.json$/, "")
|
||||
|
||||
if (urlWithoutQuery?.includes("icons/index")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
|
||||
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
@@ -325,7 +325,7 @@ importers:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
shadcn:
|
||||
specifier: 3.1.0
|
||||
specifier: 3.3.1
|
||||
version: link:../../packages/shadcn
|
||||
shiki:
|
||||
specifier: ^1.10.1
|
||||
@@ -605,7 +605,7 @@ importers:
|
||||
specifier: 2.12.7
|
||||
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
shadcn:
|
||||
specifier: 3.1.0
|
||||
specifier: 3.3.1
|
||||
version: link:../../packages/shadcn
|
||||
sharp:
|
||||
specifier: ^0.32.6
|
||||
@@ -713,12 +713,18 @@ importers:
|
||||
'@babel/plugin-transform-typescript':
|
||||
specifier: ^7.28.0
|
||||
version: 7.28.0(@babel/core@7.28.0)
|
||||
'@babel/preset-typescript':
|
||||
specifier: ^7.27.1
|
||||
version: 7.27.1(@babel/core@7.28.0)
|
||||
'@dotenvx/dotenvx':
|
||||
specifier: ^1.48.4
|
||||
version: 1.48.4
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.17.2
|
||||
version: 1.17.2
|
||||
browserslist:
|
||||
specifier: ^4.26.2
|
||||
version: 4.26.2
|
||||
commander:
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
@@ -943,18 +949,36 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.27.1':
|
||||
resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/plugin-syntax-typescript@7.27.1':
|
||||
resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/plugin-transform-modules-commonjs@7.27.1':
|
||||
resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/plugin-transform-typescript@7.28.0':
|
||||
resolution: {integrity: sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/preset-typescript@7.27.1':
|
||||
resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.28.2':
|
||||
resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -4030,6 +4054,10 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.8.4:
|
||||
resolution: {integrity: sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==}
|
||||
hasBin: true
|
||||
|
||||
basic-ftp@5.0.5:
|
||||
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -4062,8 +4090,8 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
browserslist@4.25.2:
|
||||
resolution: {integrity: sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==}
|
||||
browserslist@4.26.2:
|
||||
resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
@@ -4138,6 +4166,9 @@ packages:
|
||||
caniuse-lite@1.0.30001734:
|
||||
resolution: {integrity: sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==}
|
||||
|
||||
caniuse-lite@1.0.30001743:
|
||||
resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -4697,8 +4728,8 @@ packages:
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
electron-to-chromium@1.5.199:
|
||||
resolution: {integrity: sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==}
|
||||
electron-to-chromium@1.5.218:
|
||||
resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==}
|
||||
|
||||
embla-carousel-autoplay@8.0.0-rc15:
|
||||
resolution: {integrity: sha512-ABTbDJGNb9jzI9OV2vSpbUvxUA0ELmK0SI3yPm8Haj3ghssS+vElfahoDqp7zuFkWBRih6w3B51oMPKdF5J55A==}
|
||||
@@ -6860,8 +6891,8 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-releases@2.0.19:
|
||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||
node-releases@2.0.21:
|
||||
resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==}
|
||||
|
||||
normalize-package-data@2.5.0:
|
||||
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
|
||||
@@ -9041,7 +9072,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.28.0
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
browserslist: 4.25.2
|
||||
browserslist: 4.26.2
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
@@ -9120,11 +9151,24 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.2
|
||||
|
||||
'@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0)
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
@@ -9136,6 +9180,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/preset-typescript@7.27.1(@babel/core@7.28.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
'@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0)
|
||||
'@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.0)
|
||||
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/runtime@7.28.2': {}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
@@ -12962,7 +13017,7 @@ snapshots:
|
||||
|
||||
autoprefixer@10.4.21(postcss@8.5.6):
|
||||
dependencies:
|
||||
browserslist: 4.25.2
|
||||
browserslist: 4.26.2
|
||||
caniuse-lite: 1.0.30001734
|
||||
fraction.js: 4.3.7
|
||||
normalize-range: 0.1.2
|
||||
@@ -13019,6 +13074,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.8.4: {}
|
||||
|
||||
basic-ftp@5.0.5: {}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
@@ -13062,12 +13119,13 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
browserslist@4.25.2:
|
||||
browserslist@4.26.2:
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001734
|
||||
electron-to-chromium: 1.5.199
|
||||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.3(browserslist@4.25.2)
|
||||
baseline-browser-mapping: 2.8.4
|
||||
caniuse-lite: 1.0.30001743
|
||||
electron-to-chromium: 1.5.218
|
||||
node-releases: 2.0.21
|
||||
update-browserslist-db: 1.1.3(browserslist@4.26.2)
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
@@ -13141,6 +13199,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001734: {}
|
||||
|
||||
caniuse-lite@1.0.30001743: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@5.2.1:
|
||||
@@ -13660,7 +13720,7 @@ snapshots:
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.199: {}
|
||||
electron-to-chromium@1.5.218: {}
|
||||
|
||||
embla-carousel-autoplay@8.0.0-rc15(embla-carousel@8.0.0-rc15):
|
||||
dependencies:
|
||||
@@ -16674,7 +16734,7 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
node-releases@2.0.21: {}
|
||||
|
||||
normalize-package-data@2.5.0:
|
||||
dependencies:
|
||||
@@ -18926,9 +18986,9 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
|
||||
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
|
||||
|
||||
update-browserslist-db@1.1.3(browserslist@4.25.2):
|
||||
update-browserslist-db@1.1.3(browserslist@4.26.2):
|
||||
dependencies:
|
||||
browserslist: 4.25.2
|
||||
browserslist: 4.26.2
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
|
||||
1
registries.json
Symbolic link
1
registries.json
Symbolic link
@@ -0,0 +1 @@
|
||||
apps/v4/public/r/registries.json
|
||||
Reference in New Issue
Block a user