Compare commits

...

3 Commits

Author SHA1 Message Date
github-actions[bot]
275e3a2d59 chore(release): version packages (#8151)
* chore(release): version packages

* chore: deps

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2025-09-04 15:58:06 +04:00
shadcn
e5402f9a20 feat(shadcn): implement recursive registry namespaces (#8147)
* feat(shadcn): implement recursive registry namespaces

* fix
2025-09-04 15:40:18 +04:00
OrcDev
04668da018 feat: add @8bitcn to trusted registries (#8144) 2025-09-04 12:11:35 +04:00
10 changed files with 551 additions and 22 deletions

View File

@@ -87,7 +87,7 @@
"recharts": "2.15.1",
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"shadcn": "3.2.0",
"shadcn": "3.2.1",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.1",

View File

@@ -14,5 +14,6 @@
"@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"
"@ncdai": "https://chanhdai.com/r/{name}.json",
"@8bitcn": "https://8bitcn.com/r/{name}.json"
}

View File

@@ -88,7 +88,7 @@
"react-resizable-panels": "^2.0.22",
"react-wrap-balancer": "^0.4.1",
"recharts": "2.12.7",
"shadcn": "3.2.0",
"shadcn": "3.2.1",
"sharp": "^0.32.6",
"sonner": "^1.2.3",
"swr": "2.2.6-beta.3",

View File

@@ -1,5 +1,11 @@
# @shadcn/ui
## 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

View File

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

View 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")
})
})

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

View File

@@ -1,7 +1,7 @@
import path from "path"
import { fetchRegistries } from "@/src/registry/api"
import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
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"
@@ -21,16 +21,10 @@ export async function ensureRegistriesInConfig(
...options,
}
const registryNames = new Set<string>()
// Use resolveRegistryNamespaces to discover all namespaces including dependencies.
const registryNames = await resolveRegistryNamespaces(components, config)
for (const component of components) {
const { registry } = parseRegistryAndItemFromString(component)
if (registry) {
registryNames.add(registry)
}
}
const missingRegistries = Array.from(registryNames).filter(
const missingRegistries = registryNames.filter(
(registry) =>
!config.registries?.[registry] &&
!Object.keys(BUILTIN_REGISTRIES).includes(registry)

View File

@@ -18,12 +18,8 @@ export async function createRegistryServer(
// Handle registries.json endpoint (don't strip .json for this one)
if (urlRaw?.endsWith("/registries.json")) {
response.writeHead(200, { "Content-Type": "application/json" })
response.end(
JSON.stringify({
"@one": `http://localhost:${port}${path}/{name}`,
"@two": `http://localhost:5555/registry/{name}`,
})
)
// Return empty registry index for tests - we want to test manual configuration.
response.end(JSON.stringify({}))
return
}

4
pnpm-lock.yaml generated
View File

@@ -325,7 +325,7 @@ importers:
specifier: ^6.0.1
version: 6.0.1
shadcn:
specifier: 3.2.0
specifier: 3.2.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.2.0
specifier: 3.2.1
version: link:../../packages/shadcn
sharp:
specifier: ^0.32.6