refactor(shadcn): add getRegistryItems and resolveRegistryItems (#7983)

* feat(shadcn): refactor fetchFromRegistry

* refactor(shadcn): better api

* chore: changeset

* fix

* fix

* refactor

* refactor(shadcn): update getRegistryItems

* refactor(shadcn): error handling

* fix: getRegistryItems header context

* fix: tests

* feat(shadcn): export errors

* refactor(shadcn): getRegistryItems getRegistry

* fix

* fix

* fix

* fix

* chore: changeset

* chore: remove minor changeset
This commit is contained in:
shadcn
2025-08-10 15:20:38 +04:00
committed by GitHub
parent 6e870c3993
commit a426fea941
32 changed files with 4201 additions and 3661 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": major
---
update getRegistry, getRegistryItems and resolveRegistryItems apis

View File

@@ -1,7 +1,8 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightAdd } from "@/src/preflights/preflight-add"
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
import { getRegistryItems, getShadcnRegistryIndex } from "@/src/registry/api"
import { DEPRECATED_COMPONENTS } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { isUniversalRegistryItem } from "@/src/registry/utils"
import { addComponents } from "@/src/utils/add-components"
@@ -18,21 +19,6 @@ import { Command } from "commander"
import prompts from "prompts"
import { z } from "zod"
const DEPRECATED_COMPONENTS = [
{
name: "toast",
deprecatedBy: "sonner",
message:
"The toast component is deprecated. Use the sonner component instead.",
},
{
name: "toaster",
deprecatedBy: "sonner",
message:
"The toaster component is deprecated. Use the sonner component instead.",
},
]
export const addOptionsSchema = z.object({
components: z.array(z.string()).optional(),
yes: z.boolean(),
@@ -91,7 +77,10 @@ export const add = new Command()
}
if (components.length > 0) {
const registryItem = await getRegistryItem(components[0], initialConfig)
const [registryItem] = await getRegistryItems(
[components[0]],
initialConfig
)
const itemType = registryItem?.type
if (isUniversalRegistryItem(registryItem)) {
@@ -236,7 +225,7 @@ export const add = new Command()
async function promptForRegistryComponents(
options: z.infer<typeof addOptionsSchema>
) {
const registryIndex = await getRegistryIndex()
const registryIndex = await getShadcnRegistryIndex()
if (!registryIndex) {
logger.break()
handleError(new Error("Failed to fetch registry index."))

View File

@@ -4,7 +4,7 @@ import {
fetchTree,
getItemTargetPath,
getRegistryBaseColor,
getRegistryIndex,
getShadcnRegistryIndex,
} from "@/src/registry/api"
import { registryIndexSchema } from "@/src/registry/schema"
import { Config, getConfig } from "@/src/utils/get-config"
@@ -57,7 +57,7 @@ export const diff = new Command()
process.exit(1)
}
const registryIndex = await getRegistryIndex()
const registryIndex = await getShadcnRegistryIndex()
if (!registryIndex) {
handleError(new Error("Failed to fetch registry index."))

View File

@@ -1,13 +1,14 @@
import { promises as fs } from "fs"
import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry"
import {
BASE_COLORS,
getRegistryBaseColors,
getRegistryItem,
getRegistryItems,
getRegistryStyles,
} from "@/src/registry/api"
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
import { configWithDefaults } from "@/src/registry/config"
import { BASE_COLORS } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { rawConfigSchema } from "@/src/registry/schema"
import { addComponents } from "@/src/utils/add-components"
@@ -152,12 +153,7 @@ export const init = new Command()
if (components.length > 0) {
// We don't know the full config at this point.
// So we'll use a shadow config to fetch the first item.
let shadowConfig: Parameters<typeof getRegistryItem>[1] = {
style: "new-york",
resolvedPaths: {
cwd: "",
},
}
let shadowConfig = configWithDefaults({})
// Check if there's a components.json file.
// If so, we'll merge with our shadow config.
@@ -165,10 +161,7 @@ export const init = new Command()
if (fsExtra.existsSync(componentsJsonPath)) {
const existingConfig = await fsExtra.readJson(componentsJsonPath)
const config = rawConfigSchema.partial().parse(existingConfig)
shadowConfig = {
...shadowConfig,
...config,
}
shadowConfig = configWithDefaults(config)
// Since components.json might not be valid at this point.
// Temporarily rename components.json to allow preflight to run.
@@ -179,7 +172,7 @@ export const init = new Command()
// This forces a shadowConfig validation early in the process.
buildUrlAndHeadersForRegistryItem(components[0], shadowConfig)
const item = await getRegistryItem(components[0], shadowConfig)
const [item] = await getRegistryItems([components[0]], shadowConfig)
if (item?.type === "registry:style") {
// Set a default base color so we're not prompted.
// The style will extend or override it.
@@ -204,6 +197,9 @@ export const init = new Command()
"Success!"
)} Project initialization completed.\nYou may now add components.`
)
// We need when runninng with custom cwd.
deleteFileBackup(path.resolve(options.cwd, "components.json"))
logger.break()
} catch (error) {
logger.break()

View File

@@ -1,5 +1,6 @@
import { registrySchema } from "@/src/registry"
import { fetchRegistry, getRegistryItem } from "@/src/registry/api"
import { getRegistryItems } from "@/src/registry/api"
import { fetchRegistry } from "@/src/registry/fetcher"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import {
CallToolRequestSchema,
@@ -197,8 +198,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new Error("Name is required")
}
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
const item = await getRegistryItem(itemUrl)
const [item] = await getRegistryItems([name], undefined, {
useCache: false,
})
return {
content: [{ type: "text", text: JSON.stringify(item, null, 2) }],
@@ -213,7 +215,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
const item = await getRegistryItem(itemUrl)
const item = await getRegistryItems([itemUrl], undefined, {
useCache: false,
})
if (!item) {
return {

View File

@@ -1,33 +1,35 @@
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import { REGISTRY_URL } from "@/src/registry/constants"
import {
RegistryErrorCode,
RegistryFetchError,
RegistryForbiddenError,
RegistryLocalFileError,
RegistryNotConfiguredError,
RegistryNotFoundError,
RegistryParseError,
RegistryUnauthorizedError,
} from "@/src/registry/errors"
import { HttpResponse, http } from "msw"
import { setupServer } from "msw/node"
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest"
import {
clearRegistryCache,
fetchRegistry,
getRegistry,
getRegistryItem,
registryResolveItemsTree,
} from "./api"
import { getRegistry, getRegistryItems } from "./api"
// Mock the handleError function to prevent process.exit in tests
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn(),
}))
// Mock the logger to prevent console output in tests
vi.mock("@/src/utils/logger", () => ({
logger: {
error: vi.fn(),
@@ -36,8 +38,6 @@ vi.mock("@/src/utils/logger", () => ({
},
}))
const REGISTRY_URL = process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
const server = setupServer(
http.get(`${REGISTRY_URL}/index.json`, () => {
return HttpResponse.json([
@@ -65,6 +65,21 @@ const server = setupServer(
],
})
}),
http.get(`${REGISTRY_URL}/styles/new-york-v4/button.json`, () => {
return HttpResponse.json({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/button.tsx",
content: "// button component content v4",
type: "registry:ui",
},
],
})
}),
http.get(`${REGISTRY_URL}/styles/new-york/card.json`, () => {
return HttpResponse.json({
name: "card",
@@ -87,77 +102,7 @@ afterEach(() => {
})
afterAll(() => server.close())
describe("fetchRegistry", () => {
it("should fetch registry data", async () => {
const paths = ["styles/new-york/button.json"]
const result = await fetchRegistry(paths)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
})
})
it("should use cache for subsequent requests", async () => {
const paths = ["styles/new-york/button.json"]
let fetchCount = 0
// Clear any existing cache before test
clearRegistryCache()
// Define the handler with counter before making requests
server.use(
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, async () => {
// Add a small delay to simulate network latency
await new Promise((resolve) => setTimeout(resolve, 10))
fetchCount++
return HttpResponse.json({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/button.tsx",
content: "// button component content",
type: "registry:ui",
},
],
})
})
)
// First request
const result1 = await fetchRegistry(paths)
expect(fetchCount).toBe(1)
expect(result1).toHaveLength(1)
expect(result1[0]).toMatchObject({ name: "button" })
// Second request - should use cache
const result2 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result2).toHaveLength(1)
expect(result2[0]).toMatchObject({ name: "button" })
// Third request - double check cache
const result3 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result3).toHaveLength(1)
expect(result3[0]).toMatchObject({ name: "button" })
})
it("should handle multiple paths", async () => {
const paths = ["styles/new-york/button.json", "styles/new-york/card.json"]
const result = await fetchRegistry(paths)
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
})
describe("getRegistryItem with local files", () => {
describe("getRegistryItem", () => {
it("should read and parse a valid local JSON file", async () => {
// Create a temporary file
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
@@ -179,7 +124,7 @@ describe("getRegistryItem with local files", () => {
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const result = await getRegistryItem(tempFile)
const [result] = await getRegistryItems([tempFile])
expect(result).toMatchObject({
name: "test-component",
@@ -217,7 +162,7 @@ describe("getRegistryItem with local files", () => {
const originalCwd = process.cwd()
process.chdir(tempDir)
const result = await getRegistryItem("./relative-component.json")
const [result] = await getRegistryItems(["./relative-component.json"])
expect(result).toMatchObject({
name: "relative-component",
@@ -248,7 +193,7 @@ describe("getRegistryItem with local files", () => {
try {
// Test with tilde path
const tildeePath = "~/shadcn-test-tilde.json"
const result = await getRegistryItem(tildeePath)
const [result] = await getRegistryItems([tildeePath])
expect(result).toMatchObject({
name: "tilde-component",
@@ -260,20 +205,20 @@ describe("getRegistryItem with local files", () => {
}
})
it("should return null for non-existent files", async () => {
const result = await getRegistryItem("/non/existent/file.json")
expect(result).toBe(null)
it("should throw error for non-existent files", async () => {
await expect(getRegistryItems(["/non/existent/file.json"])).rejects.toThrow(
RegistryLocalFileError
)
})
it("should return null for invalid JSON", async () => {
it("should throw error for invalid JSON", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid.json")
await fs.writeFile(tempFile, "{ invalid json }")
try {
const result = await getRegistryItem(tempFile)
expect(result).toBe(null)
await expect(getRegistryItems([tempFile])).rejects.toThrow()
} finally {
// Clean up
await fs.unlink(tempFile)
@@ -281,7 +226,7 @@ describe("getRegistryItem with local files", () => {
}
})
it("should return null for JSON that doesn't match registry schema", async () => {
it("should throw error for JSON that doesn't match registry schema", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid-schema.json")
@@ -293,8 +238,9 @@ describe("getRegistryItem with local files", () => {
await fs.writeFile(tempFile, JSON.stringify(invalidData))
try {
const result = await getRegistryItem(tempFile)
expect(result).toBe(null)
await expect(getRegistryItems([tempFile])).rejects.toThrow(
RegistryParseError
)
} finally {
// Clean up
await fs.unlink(tempFile)
@@ -304,7 +250,7 @@ describe("getRegistryItem with local files", () => {
it("should still handle URLs and component names", async () => {
// Test that existing functionality still works
const result = await getRegistryItem("button")
const [result] = await getRegistryItems(["button"])
expect(result).toMatchObject({
name: "button",
type: "registry:ui",
@@ -349,7 +295,7 @@ describe("getRegistryItem with local files", () => {
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const result = await getRegistryItem(tempFile)
const [result] = await getRegistryItems([tempFile])
expect(result).toMatchObject({
name: "component-with-url-deps",
@@ -362,144 +308,247 @@ describe("getRegistryItem with local files", () => {
await fs.rmdir(tempDir)
}
})
})
describe("registryResolveItemsTree with URL dependencies", () => {
it("should resolve URL dependencies from local files", async () => {
// Mock a URL endpoint for dependency
const dependencyUrl = "https://example.com/dependency.json"
it("should include error code in RegistryNotFoundError", async () => {
server.use(
http.get(dependencyUrl, () => {
return HttpResponse.json({
name: "url-dependency",
type: "registry:ui",
files: [
{
path: "ui/url-dependency.tsx",
content: "// url dependency content",
type: "registry:ui",
},
],
})
http.get(`${REGISTRY_URL}/styles/new-york-v4/non-existent.json`, () => {
return HttpResponse.json({ error: "Not found" }, { status: 404 })
})
)
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "component-with-url-deps.json")
const componentData = {
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl], // URL dependency
files: [
{
path: "ui/component-with-url-deps.tsx",
content: "// component with url deps content",
type: "registry:ui",
},
],
try {
await getRegistryItems(["non-existent"])
} catch (error) {
expect(error).toBeInstanceOf(RegistryNotFoundError)
if (error instanceof RegistryNotFoundError) {
expect(error.code).toBe(RegistryErrorCode.NOT_FOUND)
expect(error.statusCode).toBe(404)
expect(error.suggestion).toContain("Check if the item name is correct")
expect(error.url).toContain("non-existent.json")
}
}
})
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
it("should include error code in RegistryUnauthorizedError", async () => {
server.use(
http.get(`${REGISTRY_URL}/styles/new-york-v4/protected.json`, () => {
return HttpResponse.json({ error: "Unauthorized" }, { status: 401 })
})
)
try {
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
resolvedPaths: { cwd: process.cwd() },
} as any
await getRegistryItems(["protected"])
} catch (error) {
expect(error).toBeInstanceOf(RegistryUnauthorizedError)
if (error instanceof RegistryUnauthorizedError) {
expect(error.code).toBe(RegistryErrorCode.UNAUTHORIZED)
expect(error.statusCode).toBe(401)
expect(error.suggestion).toContain(
"Check your authentication credentials"
)
expect(error.url).toContain("protected.json")
}
}
})
const result = await registryResolveItemsTree([tempFile], mockConfig)
it("should include error code in RegistryForbiddenError", async () => {
server.use(
http.get(`${REGISTRY_URL}/styles/new-york-v4/forbidden.json`, () => {
return HttpResponse.json({ error: "Forbidden" }, { status: 403 })
})
)
expect(result).toBeDefined()
expect(result?.files).toBeDefined()
// Should contain files from both the main component and its URL dependency
const filePaths = result?.files?.map((f: any) => f.path) ?? []
expect(filePaths).toContain("ui/component-with-url-deps.tsx")
expect(filePaths).toContain("ui/url-dependency.tsx")
try {
await getRegistryItems(["forbidden"])
} catch (error) {
expect(error).toBeInstanceOf(RegistryForbiddenError)
if (error instanceof RegistryForbiddenError) {
expect(error.code).toBe(RegistryErrorCode.FORBIDDEN)
expect(error.statusCode).toBe(403)
expect(error.suggestion).toContain(
"Check your authentication credentials"
)
expect(error.url).toContain("forbidden.json")
}
}
})
it("should include error code in RegistryFetchError for 500 errors", async () => {
server.use(
http.get(`${REGISTRY_URL}/styles/new-york-v4/server-error.json`, () => {
return HttpResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
)
})
)
try {
await getRegistryItems(["server-error"])
} catch (error) {
expect(error).toBeInstanceOf(RegistryFetchError)
if (error instanceof RegistryFetchError) {
expect(error.code).toBe(RegistryErrorCode.FETCH_ERROR)
expect(error.statusCode).toBe(500)
expect(error.suggestion).toContain(
"The registry server encountered an error"
)
expect(error.url).toContain("server-error.json")
}
}
})
it("should extract Zod validation issues in RegistryParseError", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid-schema.json")
const invalidData = {
name: "test",
// Missing required "type" field
// Invalid "files" field (should be array)
files: "not-an-array",
}
await fs.writeFile(tempFile, JSON.stringify(invalidData))
try {
await getRegistryItems([tempFile])
} catch (error) {
expect(error).toBeInstanceOf(RegistryParseError)
if (error instanceof RegistryParseError) {
expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR)
expect(error.message).toContain("Failed to parse registry item")
expect(error.message).toContain(tempFile)
// The message should include Zod validation issues
expect(error.suggestion).toContain(
"may be corrupted or have an invalid format"
)
expect(error.context?.item).toBe(tempFile)
}
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should resolve namespace syntax in registryDependencies", async () => {
// Mock a namespace registry endpoint
const namespaceUrl = "https://custom-registry.com/custom-component.json"
it("should include context in RegistryLocalFileError", async () => {
const nonExistentFile = "/path/to/non/existent/file.json"
try {
await getRegistryItems([nonExistentFile])
} catch (error) {
expect(error).toBeInstanceOf(RegistryLocalFileError)
if (error instanceof RegistryLocalFileError) {
expect(error.code).toBe(RegistryErrorCode.LOCAL_FILE_ERROR)
expect(error.filePath).toBe(nonExistentFile)
expect(error.suggestion).toContain("Check if the file exists")
expect(error.context?.filePath).toBe(nonExistentFile)
}
}
})
it("should serialize error to JSON correctly", async () => {
server.use(
http.get(namespaceUrl, () => {
return HttpResponse.json({
name: "custom-component",
type: "registry:ui",
files: [
{
path: "ui/custom-component.tsx",
content: "// custom component content",
type: "registry:ui",
},
],
})
http.get(`${REGISTRY_URL}/styles/new-york-v4/test-json.json`, () => {
return HttpResponse.json({ error: "Not found" }, { status: 404 })
})
)
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "component-with-namespace-deps.json")
const componentData = {
name: "component-with-namespace-deps",
type: "registry:ui",
registryDependencies: ["@custom/custom-component"], // Namespace dependency
files: [
{
path: "ui/component-with-namespace-deps.tsx",
content: "// component with namespace deps content",
type: "registry:ui",
},
],
try {
await getRegistryItems(["test-json"])
} catch (error) {
if (error instanceof RegistryNotFoundError) {
const json = error.toJSON()
expect(json).toHaveProperty("name", "RegistryNotFoundError")
expect(json).toHaveProperty("message")
expect(json).toHaveProperty("code", RegistryErrorCode.NOT_FOUND)
expect(json).toHaveProperty("statusCode", 404)
expect(json).toHaveProperty("context")
expect(json).toHaveProperty("suggestion")
expect(json).toHaveProperty("timestamp")
expect(json).toHaveProperty("stack")
}
}
})
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
it("should include timestamp in errors", async () => {
server.use(
http.get(`${REGISTRY_URL}/styles/new-york-v4/timestamp-test.json`, () => {
return HttpResponse.json({ error: "Not found" }, { status: 404 })
})
)
try {
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
resolvedPaths: { cwd: process.cwd() },
registries: {
"@custom": {
url: "https://custom-registry.com/{name}.json",
},
},
} as any
await getRegistryItems(["timestamp-test"])
} catch (error) {
if (error instanceof RegistryNotFoundError) {
expect(error.timestamp).toBeInstanceOf(Date)
expect(error.timestamp.getTime()).toBeLessThanOrEqual(Date.now())
}
}
})
const result = await registryResolveItemsTree([tempFile], mockConfig)
it("should handle multiple validation errors in RegistryParseError", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "multiple-errors.json")
expect(result).toBeDefined()
expect(result?.files).toBeDefined()
const invalidData = {
// Missing name and type
files: 123, // Should be array
dependencies: "not-an-array", // Should be array
}
expect(result?.files?.length).toBe(2)
expect(
result?.files?.some((f) => f.path === "ui/custom-component.tsx")
).toBe(true)
expect(
result?.files?.some(
(f) => f.path === "ui/component-with-namespace-deps.tsx"
)
).toBe(true)
await fs.writeFile(tempFile, JSON.stringify(invalidData))
try {
await getRegistryItems([tempFile])
} catch (error) {
expect(error).toBeInstanceOf(RegistryParseError)
if (error instanceof RegistryParseError) {
// The error message should contain multiple validation issues
expect(error.message).toContain("Failed to parse registry item")
// Check that context contains validation issues
if (error.context && "validationIssues" in error.context) {
const issues = error.context.validationIssues as Array<{
path: string
message: string
}>
expect(Array.isArray(issues)).toBe(true)
}
}
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should include response body in RegistryFetchError", async () => {
const errorResponse = {
error: "Bad Request",
details: "Invalid parameters",
}
server.use(
http.get(`${REGISTRY_URL}/styles/new-york-v4/bad-request.json`, () => {
return HttpResponse.json(errorResponse, { status: 400 })
})
)
try {
await getRegistryItems(["bad-request"])
} catch (error) {
expect(error).toBeInstanceOf(RegistryFetchError)
if (error instanceof RegistryFetchError) {
expect(error.code).toBe(RegistryErrorCode.FETCH_ERROR)
expect(error.statusCode).toBe(400)
expect(error.suggestion).toContain("client error")
// Response body should be available as context
expect(error.responseBody).toBeDefined()
}
}
})
})
describe("getRegistry", () => {
beforeEach(() => {
clearRegistryCache()
})
it("should fetch registry catalog", async () => {
const registryData = {
name: "@acme/registry",
@@ -526,7 +575,7 @@ describe("getRegistry", () => {
},
} as any
const result = await getRegistry("@acme/registry", mockConfig)
const result = await getRegistry("@acme", mockConfig)
expect(result).toMatchObject({
name: "@acme/registry",
@@ -538,6 +587,37 @@ describe("getRegistry", () => {
})
})
it("should auto-append /registry to registry name", async () => {
const registryData = {
name: "@acme/registry",
homepage: "https://acme.com",
items: [],
}
server.use(
http.get("https://acme.com/registry.json", () => {
return HttpResponse.json(registryData)
})
)
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
registries: {
"@acme": {
url: "https://acme.com/{name}.json",
},
},
} as any
// Test both with and without /registry suffix
const result1 = await getRegistry("@acme", mockConfig)
expect(result1.name).toBe("@acme/registry")
const result2 = await getRegistry("@acme/registry", mockConfig)
expect(result2.name).toBe("@acme/registry")
})
it("should handle registry with auth headers", async () => {
const registryData = {
name: "@private/registry",
@@ -569,7 +649,7 @@ describe("getRegistry", () => {
},
} as any
const result = await getRegistry("@private/registry", mockConfig)
const result = await getRegistry("@private", mockConfig)
expect(result).toMatchObject({
name: "@private/registry",
@@ -580,10 +660,25 @@ describe("getRegistry", () => {
expect(receivedHeaders.authorization).toBe("Bearer test-token")
})
it("should return null on error", async () => {
it("should throw RegistryNotConfiguredError when registry is not configured", async () => {
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
registries: {},
} as any
await expect(getRegistry("@unknown", mockConfig)).rejects.toThrow(
RegistryNotConfiguredError
)
})
it("should throw RegistryParseError on invalid registry data", async () => {
server.use(
http.get("https://example.com/registry.json", () => {
return HttpResponse.json({ error: "Not found" }, { status: 404 })
http.get("https://invalid.com/registry.json", () => {
return HttpResponse.json({
// Invalid registry data - missing required fields
invalid: "data",
})
})
)
@@ -591,13 +686,36 @@ describe("getRegistry", () => {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
registries: {
"@example": {
url: "https://example.com/{name}.json",
"@invalid": {
url: "https://invalid.com/{name}.json",
},
},
} as any
const result = await getRegistry("@example/registry", mockConfig)
expect(result).toBe(null)
await expect(getRegistry("@invalid", mockConfig)).rejects.toThrow(
RegistryParseError
)
})
it("should throw RegistryFetchError on network error", async () => {
server.use(
http.get("https://error.com/registry.json", () => {
return HttpResponse.json({ error: "Server Error" }, { status: 500 })
})
)
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
registries: {
"@error": {
url: "https://error.com/{name}.json",
},
},
} as any
await expect(getRegistry("@error", mockConfig)).rejects.toThrow(
RegistryFetchError
)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { REGISTRY_URL } from "@/src/registry/constants"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
buildHeadersFromRegistryConfig,
buildUrlAndHeadersForRegistryItem,
buildUrlFromRegistryConfig,
resolveRegistryUrl,
} from "./builder"
describe("buildUrlFromRegistryConfig", () => {
@@ -447,3 +450,27 @@ describe("buildUrlAndHeadersForRegistryItem", () => {
).toBeNull()
})
})
describe("resolveRegistryUrl", () => {
it("should return the URL as-is for valid URLs", () => {
const url = "https://example.com/component.json"
expect(resolveRegistryUrl(url)).toBe(url)
})
it("should append /json to v0 registry URLs", () => {
const v0Url = "https://v0.dev/chat/b/abc123"
expect(resolveRegistryUrl(v0Url)).toBe("https://v0.dev/chat/b/abc123/json")
})
it("should not append /json if already present", () => {
const v0Url = "https://v0.dev/chat/b/abc123/json"
expect(resolveRegistryUrl(v0Url)).toBe(v0Url)
})
it("should prepend REGISTRY_URL for non-URLs", () => {
expect(resolveRegistryUrl("test.json")).toBe(`${REGISTRY_URL}/test.json`)
expect(resolveRegistryUrl("styles/default/button.json")).toBe(
`${REGISTRY_URL}/styles/default/button.json`
)
})
})

View File

@@ -1,10 +1,13 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { expandEnvVars } from "@/src/registry/env"
import { RegistryNotConfiguredError } from "@/src/registry/errors"
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
import { configSchema, registryConfigItemSchema } from "@/src/registry/schema"
import { registryConfigItemSchema } from "@/src/registry/schema"
import { isUrl } from "@/src/registry/utils"
import { validateRegistryConfig } from "@/src/registry/validator"
import { Config } from "@/src/utils/get-config"
import { z } from "zod"
import { expandEnvVars } from "./env"
const NAME_PLACEHOLDER = "{name}"
const STYLE_PLACEHOLDER = "{style}"
const ENV_VAR_PATTERN = /\${(\w+)}/g
@@ -13,7 +16,7 @@ const QUERY_PARAM_DELIMITER = "&"
export function buildUrlAndHeadersForRegistryItem(
name: string,
config?: Pick<z.infer<typeof configSchema>, "registries" | "style">
config?: Config
) {
const { registry, item } = parseRegistryAndItemFromString(name)
@@ -24,10 +27,7 @@ export function buildUrlAndHeadersForRegistryItem(
const registries = config?.registries || {}
const registryConfig = registries[registry]
if (!registryConfig) {
throw new Error(
`Unknown registry "${registry}". Make sure it is defined in components.json as follows:\n` +
`{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}`
)
throw new RegistryNotConfiguredError(registry)
}
// TODO: I don't like this here.
@@ -43,7 +43,7 @@ export function buildUrlAndHeadersForRegistryItem(
export function buildUrlFromRegistryConfig(
item: string,
registryConfig: z.infer<typeof registryConfigItemSchema>,
config?: Pick<z.infer<typeof configSchema>, "style">
config?: Config
) {
if (typeof registryConfig === "string") {
let url = registryConfig.replace(NAME_PLACEHOLDER, item)
@@ -129,3 +129,25 @@ function shouldIncludeHeader(originalValue: string, expandedValue: string) {
return true
}
/**
* Resolves a registry URL from a path or URL string.
* Handles special cases like v0 registry URLs that need /json suffix.
*
* @param pathOrUrl - Either a relative path or a full URL
* @returns The resolved registry URL
*/
export function resolveRegistryUrl(pathOrUrl: string) {
if (isUrl(pathOrUrl)) {
// If the url contains /chat/b/, we assume it's the v0 registry.
// We need to add the /json suffix if it's missing.
const url = new URL(pathOrUrl)
if (url.pathname.match(/\/chat\/b\//) && !url.pathname.endsWith("/json")) {
url.pathname = `${url.pathname}/json`
}
return url.toString()
}
return `${REGISTRY_URL}/${pathOrUrl}`
}

View File

@@ -0,0 +1,221 @@
import { BUILTIN_REGISTRIES, FALLBACK_STYLE } from "@/src/registry/constants"
import { createConfig } from "@/src/utils/get-config"
import { describe, expect, it } from "vitest"
import { configWithDefaults } from "./config"
describe("configWithDefaults", () => {
it("should merge built-in registries with user registries", () => {
const userConfig = createConfig({
registries: {
"@custom": "http://example.com/{name}",
},
})
const result = configWithDefaults(userConfig)
expect(result.registries).toEqual({
...BUILTIN_REGISTRIES,
"@custom": "http://example.com/{name}",
})
})
it("should preserve user registries when merging", () => {
const userConfig = createConfig({
registries: {
"@one": "http://one.com/{name}",
"@two": {
url: "http://two.com/{name}",
headers: {
Authorization: "Bearer token",
},
},
},
})
const result = configWithDefaults(userConfig)
expect(result.registries?.["@one"]).toBe("http://one.com/{name}")
expect(result.registries?.["@two"]).toEqual({
url: "http://two.com/{name}",
headers: {
Authorization: "Bearer token",
},
})
expect(result.registries?.["@shadcn"]).toBe(BUILTIN_REGISTRIES["@shadcn"])
})
it("should use FALLBACK_STYLE when style is new-york and tailwind.config is empty", () => {
const config = createConfig({
style: "new-york",
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result = configWithDefaults(config)
expect(result.style).toBe(FALLBACK_STYLE)
})
it("should keep new-york style when tailwind.config is not empty", () => {
const config = createConfig({
style: "new-york",
tailwind: {
config: "tailwind.config.js",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result = configWithDefaults(config)
expect(result.style).toBe("new-york")
})
it("should preserve non-new-york styles regardless of tailwind config", () => {
const config1 = createConfig({
style: "default",
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result1 = configWithDefaults(config1)
expect(result1.style).toBe("default")
const config2 = createConfig({
style: "miami",
tailwind: {
config: "tailwind.config.js",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result2 = configWithDefaults(config2)
expect(result2.style).toBe("miami")
})
it("should use FALLBACK_STYLE when no style is provided", () => {
const config = createConfig({
style: undefined,
})
const result = configWithDefaults(config)
expect(result.style).toBe(FALLBACK_STYLE)
})
it("should deeply merge nested config properties", () => {
const config = createConfig({
tailwind: {
css: "custom/path/globals.css",
prefix: "tw-",
baseColor: "zinc",
cssVariables: false,
},
aliases: {
components: "@/custom-components",
utils: "@/custom-utils",
},
})
const result = configWithDefaults(config)
expect(result.tailwind.css).toBe("custom/path/globals.css")
expect(result.tailwind.prefix).toBe("tw-")
expect(result.tailwind.baseColor).toBe("zinc")
expect(result.tailwind.cssVariables).toBe(false)
expect(result.aliases.components).toBe("@/custom-components")
expect(result.aliases.utils).toBe("@/custom-utils")
})
it("should preserve all user config properties", () => {
const config = createConfig({
style: "default",
tsx: false,
rsc: false,
tailwind: {
config: "custom.config.js",
css: "styles/main.css",
baseColor: "gray",
cssVariables: true,
prefix: "app-",
},
resolvedPaths: {
cwd: "/custom/project",
tailwindConfig: "/custom/project/tailwind.config.js",
tailwindCss: "/custom/project/styles/main.css",
utils: "/custom/project/lib/utils",
components: "/custom/project/components",
ui: "/custom/project/components/ui",
lib: "/custom/project/lib",
hooks: "/custom/project/hooks",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
})
const result = configWithDefaults(config)
expect(result.tsx).toBe(false)
expect(result.rsc).toBe(false)
expect(result.tailwind.config).toBe("custom.config.js")
expect(result.tailwind.css).toBe("styles/main.css")
expect(result.tailwind.baseColor).toBe("gray")
expect(result.tailwind.prefix).toBe("app-")
expect(result.resolvedPaths.cwd).toBe("/custom/project")
expect(result.resolvedPaths.components).toBe("/custom/project/components")
})
it("should handle empty registries object", () => {
const config = createConfig({
registries: {},
})
const result = configWithDefaults(config)
expect(result.registries).toEqual(BUILTIN_REGISTRIES)
})
it("should override built-in registries if user provides same key", () => {
const config = createConfig({
registries: {
"@shadcn": "http://custom-shadcn.com/{name}",
},
})
const result = configWithDefaults(config)
// User's @shadcn should override the built-in one
expect(result.registries?.["@shadcn"]).toBe(
"http://custom-shadcn.com/{name}"
)
})
it("should validate the final config with configSchema", () => {
const config = createConfig({
style: "default",
registries: {
"@test": "http://test.com/{name}",
},
})
// This should not throw since configSchema.parse is called internally
expect(() => configWithDefaults(config)).not.toThrow()
})
})

View File

@@ -0,0 +1,37 @@
import { BUILTIN_REGISTRIES, FALLBACK_STYLE } from "@/src/registry/constants"
import { configSchema } from "@/src/registry/schema"
import { Config, createConfig } from "@/src/utils/get-config"
import deepmerge from "deepmerge"
function resolveStyleFromConfig(config: Partial<Config> | Config) {
if (!config.style) {
return FALLBACK_STYLE
}
// Check if we should use new-york-v4 for Tailwind v4.
// We assume that if tailwind.config is empty, we're using Tailwind v4.
if (config.style === "new-york" && config.tailwind?.config === "") {
return FALLBACK_STYLE
}
return config.style
}
export function configWithDefaults(config?: Partial<Config> | Config) {
const baseConfig = createConfig({
style: FALLBACK_STYLE,
registries: BUILTIN_REGISTRIES,
})
if (!config) {
return baseConfig
}
return configSchema.parse(
deepmerge(baseConfig, {
...config,
style: resolveStyleFromConfig(config),
registries: { ...BUILTIN_REGISTRIES, ...config.registries },
})
)
}

View File

@@ -1,10 +1,34 @@
import { registryConfigSchema } from "@/src/registry/schema"
import { z } from "zod"
import { registryConfigSchema } from "./schema"
export const REGISTRY_URL =
process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
export const FALLBACK_STYLE = "new-york-v4"
export const BASE_COLORS = [
{
name: "neutral",
label: "Neutral",
},
{
name: "gray",
label: "Gray",
},
{
name: "zinc",
label: "Zinc",
},
{
name: "stone",
label: "Stone",
},
{
name: "slate",
label: "Slate",
},
] as const
// Built-in registries that are always available and cannot be overridden
export const BUILTIN_REGISTRIES: z.infer<typeof registryConfigSchema> = {
"@shadcn": `${REGISTRY_URL}/styles/{style}/{name}.json`,
@@ -133,3 +157,18 @@ export const BUILTIN_MODULES = new Set([
"bun:internal",
],
])
export const DEPRECATED_COMPONENTS = [
{
name: "toast",
deprecatedBy: "sonner",
message:
"The toast component is deprecated. Use the sonner component instead.",
},
{
name: "toaster",
deprecatedBy: "sonner",
message:
"The toaster component is deprecated. Use the sonner component instead.",
},
]

View File

@@ -9,7 +9,8 @@ let context: RegistryContext = {
export function setRegistryHeaders(
headers: Record<string, Record<string, string>>
) {
context.headers = headers
// Merge new headers with existing ones to preserve headers for nested dependencies
context.headers = { ...context.headers, ...headers }
}
export function getRegistryHeadersFromContext(

View File

@@ -0,0 +1,238 @@
import { z } from "zod"
// Error codes for programmatic error handling
export const RegistryErrorCode = {
// Network errors
NETWORK_ERROR: "NETWORK_ERROR",
NOT_FOUND: "NOT_FOUND",
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
FETCH_ERROR: "FETCH_ERROR",
// Configuration errors
NOT_CONFIGURED: "NOT_CONFIGURED",
INVALID_CONFIG: "INVALID_CONFIG",
MISSING_ENV_VARS: "MISSING_ENV_VARS",
// File system errors
LOCAL_FILE_ERROR: "LOCAL_FILE_ERROR",
// Parsing errors
PARSE_ERROR: "PARSE_ERROR",
VALIDATION_ERROR: "VALIDATION_ERROR",
// Generic errors
UNKNOWN_ERROR: "UNKNOWN_ERROR",
} as const
export type RegistryErrorCode =
(typeof RegistryErrorCode)[keyof typeof RegistryErrorCode]
export class RegistryError extends Error {
public readonly code: RegistryErrorCode
public readonly statusCode?: number
public readonly context?: Record<string, unknown>
public readonly suggestion?: string
public readonly timestamp: Date
public readonly cause?: unknown
constructor(
message: string,
options: {
code?: RegistryErrorCode
statusCode?: number
cause?: unknown
context?: Record<string, unknown>
suggestion?: string
} = {}
) {
super(message)
this.name = "RegistryError"
this.code = options.code || RegistryErrorCode.UNKNOWN_ERROR
this.statusCode = options.statusCode
this.cause = options.cause
this.context = options.context
this.suggestion = options.suggestion
this.timestamp = new Date()
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor)
}
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
context: this.context,
suggestion: this.suggestion,
timestamp: this.timestamp,
stack: this.stack,
}
}
}
export class RegistryNotFoundError extends RegistryError {
constructor(public readonly url: string, cause?: unknown) {
const message = `The item at ${url} was not found. It may not exist at the registry.`
super(message, {
code: RegistryErrorCode.NOT_FOUND,
statusCode: 404,
cause,
context: { url },
suggestion:
"Check if the item name is correct and the registry URL is accessible.",
})
this.name = "RegistryNotFoundError"
}
}
export class RegistryUnauthorizedError extends RegistryError {
constructor(public readonly url: string, cause?: unknown) {
const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.`
super(message, {
code: RegistryErrorCode.UNAUTHORIZED,
statusCode: 401,
cause,
context: { url },
suggestion:
"Check your authentication credentials and environment variables.",
})
this.name = "RegistryUnauthorizedError"
}
}
export class RegistryForbiddenError extends RegistryError {
constructor(public readonly url: string, cause?: unknown) {
const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.`
super(message, {
code: RegistryErrorCode.FORBIDDEN,
statusCode: 403,
cause,
context: { url },
suggestion:
"Check your authentication credentials and environment variables.",
})
this.name = "RegistryForbiddenError"
}
}
export class RegistryFetchError extends RegistryError {
constructor(
public readonly url: string,
statusCode?: number,
public readonly responseBody?: string,
cause?: unknown
) {
// Use the error detail from the server if available
const baseMessage = statusCode
? `Failed to fetch from registry (${statusCode}): ${url}`
: `Failed to fetch from registry: ${url}`
const message =
typeof cause === "string" && cause
? `${baseMessage} - ${cause}`
: baseMessage
let suggestion = "Check your network connection and try again."
if (statusCode === 404) {
suggestion =
"The requested resource was not found. Check the URL or item name."
} else if (statusCode === 500) {
suggestion = "The registry server encountered an error. Try again later."
} else if (statusCode && statusCode >= 400 && statusCode < 500) {
suggestion = "There was a client error. Check your request parameters."
}
super(message, {
code: RegistryErrorCode.FETCH_ERROR,
statusCode,
cause,
context: { url, responseBody },
suggestion,
})
this.name = "RegistryFetchError"
}
}
export class RegistryNotConfiguredError extends RegistryError {
constructor(public readonly registryName: string | null) {
const message = registryName
? `Unknown registry "${registryName}". Make sure it is defined in components.json as follows:
{
"registries": {
"${registryName}": "[URL_TO_REGISTRY]"
}
}`
: `Unknown registry. Make sure it is defined in components.json under "registries".`
super(message, {
code: RegistryErrorCode.NOT_CONFIGURED,
context: { registryName },
suggestion:
"Add the registry configuration to your components.json file. Consult the registry documentation for the correct format.",
})
this.name = "RegistryNotConfiguredError"
}
}
export class RegistryLocalFileError extends RegistryError {
constructor(public readonly filePath: string, cause?: unknown) {
super(`Failed to read local registry file: ${filePath}`, {
code: RegistryErrorCode.LOCAL_FILE_ERROR,
cause,
context: { filePath },
suggestion: "Check if the file exists and you have read permissions.",
})
this.name = "RegistryLocalFileError"
}
}
export class RegistryParseError extends RegistryError {
public readonly parseError: unknown
constructor(public readonly item: string, parseError: unknown) {
let message = `Failed to parse registry item: ${item}`
if (parseError instanceof z.ZodError) {
message = `Failed to parse registry item: ${item}\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
}
super(message, {
code: RegistryErrorCode.PARSE_ERROR,
cause: parseError,
context: { item },
suggestion:
"The registry item may be corrupted or have an invalid format. Please make sure it returns a valid JSON object. See https://ui.shadcn.com/schema/registry-item.json.",
})
this.parseError = parseError
this.name = "RegistryParseError"
}
}
export class RegistryMissingEnvironmentVariablesError extends RegistryError {
constructor(
public readonly registryName: string,
public readonly missingVars: string[]
) {
const message =
`Registry "${registryName}" requires the following environment variables:\n\n` +
missingVars.map((v) => `${v}`).join("\n")
super(message, {
code: RegistryErrorCode.MISSING_ENV_VARS,
context: { registryName, missingVars },
suggestion:
"Set the required environment variables to your .env or .env.local file.",
})
this.name = "RegistryMissingEnvironmentVariablesError"
}
}

View File

@@ -0,0 +1,222 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import {
RegistryFetchError,
RegistryForbiddenError,
RegistryNotFoundError,
RegistryUnauthorizedError,
} from "@/src/registry/errors"
import { HttpResponse, http } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
import { clearRegistryCache, fetchRegistry } from "./fetcher"
const server = setupServer(
http.get(`${REGISTRY_URL}/test.json`, () => {
return HttpResponse.json({
name: "test",
type: "registry:ui",
})
}),
http.get(`${REGISTRY_URL}/error.json`, () => {
return HttpResponse.error()
}),
http.get(`${REGISTRY_URL}/not-found.json`, () => {
return new HttpResponse(null, { status: 404 })
}),
http.get(`${REGISTRY_URL}/unauthorized.json`, () => {
return new HttpResponse(null, { status: 401 })
}),
http.get(`${REGISTRY_URL}/forbidden.json`, () => {
return new HttpResponse(null, { status: 403 })
}),
http.get("https://external.com/component.json", () => {
return HttpResponse.json({
name: "external",
type: "registry:ui",
})
})
)
beforeAll(() => server.listen())
afterEach(() => {
server.resetHandlers()
clearRegistryCache()
})
afterAll(() => server.close())
describe("fetchRegistry", () => {
it("should fetch a single registry item", async () => {
const result = await fetchRegistry(["test.json"])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "test",
type: "registry:ui",
})
})
it("should fetch multiple registry items in parallel", async () => {
const result = await fetchRegistry(["test.json", "test.json"])
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ name: "test" })
expect(result[1]).toMatchObject({ name: "test" })
})
it("should fetch from external URLs", async () => {
const result = await fetchRegistry(["https://external.com/component.json"])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "external",
type: "registry:ui",
})
})
it("should use cache when enabled", async () => {
// First fetch - should hit the server
const result1 = await fetchRegistry(["test.json"], { useCache: true })
expect(result1[0]).toMatchObject({ name: "test" })
// Second fetch - should use cache
const result2 = await fetchRegistry(["test.json"], { useCache: true })
expect(result2[0]).toMatchObject({ name: "test" })
})
it("should not use cache when disabled", async () => {
// Mock the server to return different responses
let callCount = 0
server.use(
http.get(`${REGISTRY_URL}/cache-test.json`, () => {
callCount++
return HttpResponse.json({
name: `test-${callCount}`,
type: "registry:ui",
})
})
)
const result1 = await fetchRegistry(["cache-test.json"], {
useCache: false,
})
expect(result1[0]).toMatchObject({ name: "test-1" })
const result2 = await fetchRegistry(["cache-test.json"], {
useCache: false,
})
expect(result2[0]).toMatchObject({ name: "test-2" })
})
it("should handle 404 errors", async () => {
await expect(fetchRegistry(["not-found.json"])).rejects.toThrow(
RegistryNotFoundError
)
})
it("should handle 401 errors", async () => {
await expect(fetchRegistry(["unauthorized.json"])).rejects.toThrow(
RegistryUnauthorizedError
)
})
it("should handle 403 errors", async () => {
await expect(fetchRegistry(["forbidden.json"])).rejects.toThrow(
RegistryForbiddenError
)
})
it("should handle network errors", async () => {
await expect(fetchRegistry(["error.json"])).rejects.toThrow()
})
it("should fetch registry data", async () => {
const paths = ["styles/new-york/button.json"]
const result = await fetchRegistry(paths)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
})
})
it("should use cache for subsequent requests", async () => {
const paths = ["styles/new-york/button.json"]
let fetchCount = 0
// Clear any existing cache before test
clearRegistryCache()
// Define the handler with counter before making requests
server.use(
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, async () => {
// Add a small delay to simulate network latency
await new Promise((resolve) => setTimeout(resolve, 10))
fetchCount++
return HttpResponse.json({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/button.tsx",
content: "// button component content",
type: "registry:ui",
},
],
})
})
)
// First request
const result1 = await fetchRegistry(paths)
expect(fetchCount).toBe(1)
expect(result1).toHaveLength(1)
expect(result1[0]).toMatchObject({ name: "button" })
// Second request - should use cache
const result2 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result2).toHaveLength(1)
expect(result2[0]).toMatchObject({ name: "button" })
// Third request - double check cache
const result3 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result3).toHaveLength(1)
expect(result3[0]).toMatchObject({ name: "button" })
})
it("should handle multiple paths", async () => {
const paths = ["styles/new-york/button.json", "styles/new-york/card.json"]
const result = await fetchRegistry(paths)
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
})
describe("clearRegistryCache", () => {
it("should clear the cache", async () => {
// First fetch - should hit the server
const result1 = await fetchRegistry(["test.json"], { useCache: true })
expect(result1[0]).toMatchObject({ name: "test" })
// Clear cache
clearRegistryCache()
// Mock the server to return different response
server.use(
http.get(`${REGISTRY_URL}/test.json`, () => {
return HttpResponse.json({
name: "test-after-clear",
type: "registry:ui",
})
})
)
// Third fetch - should hit the server again after cache clear
const result3 = await fetchRegistry(["test.json"], { useCache: true })
expect(result3[0]).toMatchObject({ name: "test-after-clear" })
})
})

View File

@@ -0,0 +1,156 @@
import { promises as fs } from "fs"
import { homedir } from "os"
import path from "path"
import { resolveRegistryUrl } from "@/src/registry/builder"
import { getRegistryHeadersFromContext } from "@/src/registry/context"
import {
RegistryFetchError,
RegistryForbiddenError,
RegistryLocalFileError,
RegistryNotFoundError,
RegistryParseError,
RegistryUnauthorizedError,
} from "@/src/registry/errors"
import { registryItemSchema } from "@/src/registry/schema"
import { HttpsProxyAgent } from "https-proxy-agent"
import fetch from "node-fetch"
import { z } from "zod"
const agent = process.env.https_proxy
? new HttpsProxyAgent(process.env.https_proxy)
: undefined
const registryCache = new Map<string, Promise<any>>()
export function clearRegistryCache() {
registryCache.clear()
}
export async function fetchRegistry(
paths: string[],
options: { useCache?: boolean } = {}
) {
options = {
useCache: true,
...options,
}
try {
const results = await Promise.all(
paths.map(async (path) => {
const url = resolveRegistryUrl(path)
// Check cache first if caching is enabled
if (options.useCache && registryCache.has(url)) {
return registryCache.get(url)
}
// Store the promise in the cache before awaiting if caching is enabled.
const fetchPromise = (async () => {
// Get headers from context for this URL.
const headers = getRegistryHeadersFromContext(url)
const response = await fetch(url, {
agent,
headers: {
...headers,
},
})
if (!response.ok) {
let messageFromServer = undefined
if (
response.headers.get("content-type")?.includes("application/json")
) {
const json = await response.json()
const parsed = z
.object({
// RFC 7807.
detail: z.string().optional(),
title: z.string().optional(),
// Standard error response.
message: z.string().optional(),
error: z.string().optional(),
})
.safeParse(json)
if (parsed.success) {
// Prefer RFC 7807 detail field, then message field.
messageFromServer = parsed.data.detail || parsed.data.message
if (parsed.data.error) {
messageFromServer = `[${parsed.data.error}] ${messageFromServer}`
}
}
}
if (response.status === 401) {
throw new RegistryUnauthorizedError(url, messageFromServer)
}
if (response.status === 404) {
throw new RegistryNotFoundError(url, messageFromServer)
}
if (response.status === 403) {
throw new RegistryForbiddenError(url, messageFromServer)
}
throw new RegistryFetchError(
url,
response.status,
messageFromServer
)
}
return response.json()
})()
if (options.useCache) {
registryCache.set(url, fetchPromise)
}
return fetchPromise
})
)
return results
} catch (error) {
throw error
}
}
export async function fetchRegistryLocal(filePath: string) {
try {
// Handle tilde expansion for home directory
let expandedPath = filePath
if (filePath.startsWith("~/")) {
expandedPath = path.join(homedir(), filePath.slice(2))
}
const resolvedPath = path.resolve(expandedPath)
const content = await fs.readFile(resolvedPath, "utf8")
const parsed = JSON.parse(content)
try {
return registryItemSchema.parse(parsed)
} catch (error) {
throw new RegistryParseError(filePath, error)
}
} catch (error) {
// Check if this is a file not found error
if (
error instanceof Error &&
(error.message.includes("ENOENT") ||
error.message.includes("no such file"))
) {
throw new RegistryLocalFileError(filePath, error)
}
// Re-throw parse errors as-is
if (error instanceof RegistryParseError) {
throw error
}
// For other errors (like JSON parse errors), throw as local file error
throw new RegistryLocalFileError(filePath, error)
}
}

View File

@@ -1,7 +1,20 @@
// TODO: Move to a separate file to support client-side usage.
export * from "./schema"
// TODO: Remove these once we have a proper api.
export { resolveRegistryTree as internal_registryResolveItemsTree } from "./resolver"
export { fetchRegistry } from "./fetcher"
export { getRegistryItems, resolveRegistryItems } from "./api"
export {
registryResolveItemsTree as internal_registryResolveItemsTree,
fetchRegistry,
} from "./api"
export { BUILTIN_REGISTRIES, REGISTRY_URL } from "./constants"
export { buildUrlAndHeadersForRegistryItem } from "./builder"
RegistryError,
RegistryNotFoundError,
RegistryUnauthorizedError,
RegistryForbiddenError,
RegistryFetchError,
RegistryNotConfiguredError,
RegistryLocalFileError,
RegistryParseError,
RegistryMissingEnvironmentVariablesError,
} from "./errors"

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,39 @@
import { configSchema } from "@/src/registry/schema"
import { createHash } from "crypto"
import path from "path"
import {
getRegistryBaseColor,
getShadcnRegistryIndex,
} from "@/src/registry/api"
import {
buildUrlAndHeadersForRegistryItem,
resolveRegistryUrl,
} from "@/src/registry/builder"
import { setRegistryHeaders } from "@/src/registry/context"
import {
RegistryNotConfiguredError,
RegistryParseError,
} from "@/src/registry/errors"
import { fetchRegistry, fetchRegistryLocal } from "@/src/registry/fetcher"
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
import {
registryItemSchema,
registryResolvedItemsTreeSchema,
} from "@/src/registry/schema"
import {
deduplicateFilesByTarget,
isLocalFile,
isUrl,
} from "@/src/registry/utils"
import { Config, getTargetStyleFromConfig } from "@/src/utils/get-config"
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { buildTailwindThemeColorsFromCssVars } from "@/src/utils/updaters/update-tailwind-config"
import deepmerge from "deepmerge"
import { z } from "zod"
import { buildUrlAndHeadersForRegistryItem } from "./builder"
import { setRegistryHeaders } from "./context"
export function resolveRegistryItemsFromRegistries(
items: string[],
config?: Pick<z.infer<typeof configSchema>, "registries" | "style">
config: Config
) {
const registryHeaders: Record<string, Record<string, string>> = {}
const resolvedItems = [...items]
@@ -32,3 +59,668 @@ export function resolveRegistryItemsFromRegistries(
return resolvedItems
}
// Internal function that fetches registry items without clearing context.
// This is used for recursive dependency resolution.
export async function fetchRegistryItems(
items: string[],
config: Config,
options: { useCache?: boolean } = {}
) {
const results = await Promise.all(
items.map(async (item) => {
if (isLocalFile(item)) {
return fetchRegistryLocal(item)
}
if (isUrl(item)) {
const [result] = await fetchRegistry([item], options)
try {
return registryItemSchema.parse(result)
} catch (error) {
throw new RegistryParseError(item, error)
}
}
if (item.startsWith("@") && config?.registries) {
const paths = resolveRegistryItemsFromRegistries([item], config)
const [result] = await fetchRegistry(paths, options)
try {
return registryItemSchema.parse(result)
} catch (error) {
throw new RegistryParseError(item, error)
}
}
const path = `styles/${config?.style ?? "new-york-v4"}/${item}.json`
const [result] = await fetchRegistry([path], options)
try {
return registryItemSchema.parse(result)
} catch (error) {
throw new RegistryParseError(item, error)
}
})
)
return results
}
// Helper schema for items with source tracking
const registryItemWithSourceSchema = registryItemSchema.extend({
_source: z.string().optional(),
})
// Resolves a list of registry items with all their dependencies and returns
// a complete installation bundle with merged configuration.
export async function resolveRegistryTree(
names: z.infer<typeof registryItemSchema>["name"][],
config: Config,
options: { useCache?: boolean } = {}
) {
try {
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])
}
}
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
// 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)
)
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
)
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)
}
}
}
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
} catch (error) {
handleError(error)
return null
}
}
async function resolveDependenciesRecursively(
dependencies: string[],
config: Config,
options: { useCache?: boolean } = {},
visited: Set<string> = new Set()
) {
const items: z.infer<typeof registryItemSchema>[] = []
const registryNames: string[] = []
for (const dep of dependencies) {
if (visited.has(dep)) {
continue
}
visited.add(dep)
// Handle URLs and local files directly.
if (isUrl(dep) || isLocalFile(dep)) {
const [item] = await fetchRegistryItems([dep], config, options)
if (item) {
items.push(item)
if (item.registryDependencies) {
// Resolve namespaced dependencies to set proper headers.
const resolvedDeps = config?.registries
? resolveRegistryItemsFromRegistries(
item.registryDependencies,
config
)
: item.registryDependencies
const nested = await resolveDependenciesRecursively(
resolvedDeps,
config,
options,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
}
}
// Handle namespaced items (e.g., @one/foo, @two/bar).
else if (dep.startsWith("@") && config?.registries) {
// Check if the registry exists.
const { registry } = parseRegistryAndItemFromString(dep)
if (registry && !(registry in config.registries)) {
throw new RegistryNotConfiguredError(registry)
}
// Let getRegistryItem handle the namespaced item with config
// This ensures proper authentication headers are used
const [item] = await fetchRegistryItems([dep], config, options)
if (item) {
items.push(item)
if (item.registryDependencies) {
// Resolve namespaced dependencies to set proper headers.
const resolvedDeps = config?.registries
? resolveRegistryItemsFromRegistries(
item.registryDependencies,
config
)
: item.registryDependencies
const nested = await resolveDependenciesRecursively(
resolvedDeps,
config,
options,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
}
}
// Handle regular component names.
else {
registryNames.push(dep)
if (config) {
try {
const [item] = await fetchRegistryItems([dep], config, options)
if (item && item.registryDependencies) {
// Resolve namespaced dependencies to set proper headers.
const resolvedDeps = config?.registries
? resolveRegistryItemsFromRegistries(
item.registryDependencies,
config
)
: item.registryDependencies
const nested = await resolveDependenciesRecursively(
resolvedDeps,
config,
options,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
} catch (error) {
// If we can't fetch the registry item, that's okay - we'll still
// include the name.
}
}
}
}
return { items, registryNames }
}
async function resolveRegistryDependencies(
url: string,
config: Config,
options: { useCache?: boolean } = {}
) {
if (isUrl(url)) {
return [url]
}
const { registryNames } = await resolveDependenciesRecursively(
[url],
config,
options,
new Set()
)
const style = config.resolvedPaths?.cwd
? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style)
: config.style
const urls = registryNames.map((name) =>
resolveRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`)
)
return Array.from(new Set(urls))
}
async function registryGetTheme(name: string, config: Config) {
const [baseColor, tailwindVersion] = await Promise.all([
getRegistryBaseColor(name),
getProjectTailwindVersionFromConfig(config),
])
if (!baseColor) {
return null
}
// TODO: Move this to the registry i.e registry:theme.
const theme = {
name,
type: "registry:theme",
tailwind: {
config: {
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {},
},
},
},
},
cssVars: {
theme: {},
light: {
radius: "0.5rem",
},
dark: {},
},
} satisfies z.infer<typeof registryItemSchema>
if (config.tailwind.cssVariables) {
theme.tailwind.config.theme.extend.colors = {
...theme.tailwind.config.theme.extend.colors,
...buildTailwindThemeColorsFromCssVars(baseColor.cssVars.dark ?? {}),
}
theme.cssVars = {
theme: {
...baseColor.cssVars.theme,
...theme.cssVars.theme,
},
light: {
...baseColor.cssVars.light,
...theme.cssVars.light,
},
dark: {
...baseColor.cssVars.dark,
...theme.cssVars.dark,
},
}
if (tailwindVersion === "v4" && baseColor.cssVarsV4) {
theme.cssVars = {
theme: {
...baseColor.cssVarsV4.theme,
...theme.cssVars.theme,
},
light: {
radius: "0.625rem",
...baseColor.cssVarsV4.light,
},
dark: {
...baseColor.cssVarsV4.dark,
},
}
}
}
return theme
}
function computeItemHash(
item: Pick<z.infer<typeof registryItemSchema>, "name">,
source?: string
) {
const identifier = source || item.name
const hash = createHash("sha256")
.update(identifier)
.digest("hex")
.substring(0, 8)
return `${item.name}::${hash}`
}
function extractItemIdentifierFromDependency(dependency: string) {
if (isUrl(dependency)) {
const url = new URL(dependency)
const pathname = url.pathname
const match = pathname.match(/\/([^/]+)\.json$/)
const name = match ? match[1] : path.basename(pathname, ".json")
return {
name,
hash: computeItemHash({ name }, dependency),
}
}
if (isLocalFile(dependency)) {
const match = dependency.match(/\/([^/]+)\.json$/)
const name = match ? match[1] : path.basename(dependency, ".json")
return {
name,
hash: computeItemHash({ name }, dependency),
}
}
const { item } = parseRegistryAndItemFromString(dependency)
return {
name: item,
hash: computeItemHash({ name: item }, dependency),
}
}
function topologicalSortRegistryItems(
items: z.infer<typeof registryItemSchema>[],
sourceMap: Map<z.infer<typeof registryItemSchema>, string>
) {
const itemMap = new Map<string, z.infer<typeof registryItemSchema>>()
const hashToItem = new Map<string, z.infer<typeof registryItemSchema>>()
const inDegree = new Map<string, number>()
const adjacencyList = new Map<string, string[]>()
items.forEach((item) => {
const source = sourceMap.get(item) || item.name
const hash = computeItemHash(item, source)
itemMap.set(hash, item)
hashToItem.set(hash, item)
inDegree.set(hash, 0)
adjacencyList.set(hash, [])
})
// Build a map of dependency to possible items.
const depToHashes = new Map<string, string[]>()
items.forEach((item) => {
const source = sourceMap.get(item) || item.name
const hash = computeItemHash(item, source)
if (!depToHashes.has(item.name)) {
depToHashes.set(item.name, [])
}
depToHashes.get(item.name)!.push(hash)
if (source !== item.name) {
if (!depToHashes.has(source)) {
depToHashes.set(source, [])
}
depToHashes.get(source)!.push(hash)
}
})
items.forEach((item) => {
const itemSource = sourceMap.get(item) || item.name
const itemHash = computeItemHash(item, itemSource)
if (item.registryDependencies) {
item.registryDependencies.forEach((dep) => {
let depHash: string | undefined
const exactMatches = depToHashes.get(dep) || []
if (exactMatches.length === 1) {
depHash = exactMatches[0]
} else if (exactMatches.length > 1) {
// Multiple matches - try to disambiguate.
// For now, just use the first one and warn.
depHash = exactMatches[0]
} else {
const { name } = extractItemIdentifierFromDependency(dep)
const nameMatches = depToHashes.get(name) || []
if (nameMatches.length > 0) {
depHash = nameMatches[0]
}
}
if (depHash && itemMap.has(depHash)) {
adjacencyList.get(depHash)!.push(itemHash)
inDegree.set(itemHash, inDegree.get(itemHash)! + 1)
}
})
}
})
// Implements Kahn's algorithm.
const queue: string[] = []
const sorted: z.infer<typeof registryItemSchema>[] = []
inDegree.forEach((degree, hash) => {
if (degree === 0) {
queue.push(hash)
}
})
while (queue.length > 0) {
const currentHash = queue.shift()!
const item = itemMap.get(currentHash)!
sorted.push(item)
adjacencyList.get(currentHash)!.forEach((dependentHash) => {
const newDegree = inDegree.get(dependentHash)! - 1
inDegree.set(dependentHash, newDegree)
if (newDegree === 0) {
queue.push(dependentHash)
}
})
}
if (sorted.length !== items.length) {
console.warn("Circular dependency detected in registry items")
// Return all items even if there are circular dependencies
// Items not in sorted are part of circular dependencies
const sortedHashes = new Set(
sorted.map((item) => {
const source = sourceMap.get(item) || item.name
return computeItemHash(item, source)
})
)
items.forEach((item) => {
const source = sourceMap.get(item) || item.name
const hash = computeItemHash(item, source)
if (!sortedHashes.has(hash)) {
sorted.push(item)
}
})
}
return sorted
}

View File

@@ -607,3 +607,18 @@ describe("canDeduplicateFiles", () => {
expect(canDeduplicateFiles(undefined as any)).toBe(false)
})
})
describe("isUrl", () => {
it("should return true for valid URLs", () => {
expect(isUrl("https://example.com")).toBe(true)
expect(isUrl("http://localhost:3000")).toBe(true)
expect(isUrl("https://example.com/path/to/file.json")).toBe(true)
})
it("should return false for non-URLs", () => {
expect(isUrl("not-a-url")).toBe(false)
expect(isUrl("/path/to/file")).toBe(false)
expect(isUrl("./relative/path")).toBe(false)
expect(isUrl("~/home/path")).toBe(false)
})
})

View File

@@ -2,6 +2,7 @@ import * as fs from "fs/promises"
import { tmpdir } from "os"
import * as path from "path"
import { configSchema, registryItemSchema } from "@/src/registry"
import { registryItemFileSchema } from "@/src/registry/schema"
import { Config } from "@/src/utils/get-config"
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
import { resolveImport } from "@/src/utils/resolve-import"
@@ -13,8 +14,6 @@ import { Project, ScriptKind } from "ts-morph"
import { loadConfig } from "tsconfig-paths"
import { z } from "zod"
import { registryItemFileSchema } from "./schema"
const FILE_EXTENSIONS_FOR_LOOKUP = [".tsx", ".ts", ".jsx", ".js", ".css"]
const FILE_PATH_SKIP_LIST = ["lib/utils.ts"]
const DEPENDENCY_SKIP_LIST = [

View File

@@ -92,7 +92,7 @@ describe("validateRegistryConfig", () => {
it("should throw when env vars are missing", () => {
expect(() => {
validateRegistryConfig("@test", "https://api.com?token=${MISSING}")
}).toThrow(/Registry "@test" requires environment variables/)
}).toThrow(/Registry "@test" requires the following environment variables/)
})
it("should list all missing variables", () => {

View File

@@ -1,8 +1,8 @@
import { extractEnvVars } from "@/src/registry/env"
import { RegistryMissingEnvironmentVariablesError } from "@/src/registry/errors"
import { registryConfigItemSchema } from "@/src/registry/schema"
import { z } from "zod"
import { extractEnvVars } from "./env"
import { registryConfigItemSchema } from "./schema"
export function extractEnvVarsFromRegistryConfig(
config: z.infer<typeof registryConfigItemSchema>
): string[] {
@@ -37,20 +37,6 @@ export function validateRegistryConfig(
const missing = requiredVars.filter((v) => !process.env[v])
if (missing.length > 0) {
const suggestions = missing.map((v) => {
// Common patterns for environment variable names
if (v.includes("TOKEN")) return `export ${v}="your-token-here"`
if (v.includes("KEY")) return `export ${v}="your-api-key-here"`
if (v.includes("SECRET")) return `export ${v}="your-secret-here"`
return `export ${v}="your-value-here"`
})
throw new Error(
`Registry "${registryName}" requires environment variables:\n\n` +
missing.map((v) => `${v}`).join("\n") +
"\n\nSet them in your environment:\n\n" +
suggestions.map((s) => ` ${s}`).join("\n") +
"\n\nOr add them to a .env file in your project root."
)
throw new RegistryMissingEnvironmentVariablesError(registryName, missing)
}
}

View File

@@ -1,5 +1,7 @@
import path from "path"
import { getRegistryItem, registryResolveItemsTree } from "@/src/registry/api"
import { getRegistryItems } from "@/src/registry/api"
import { configWithDefaults } from "@/src/registry/config"
import { resolveRegistryTree } from "@/src/registry/resolver"
import {
configSchema,
registryItemFileSchema,
@@ -77,7 +79,7 @@ async function addProjectComponents(
const registrySpinner = spinner(`Checking registry.`, {
silent: options.silent,
})?.start()
const tree = await registryResolveItemsTree(components, config)
const tree = await resolveRegistryTree(components, configWithDefaults(config))
if (!tree) {
registrySpinner?.fail()
@@ -151,7 +153,7 @@ async function addWorkspaceComponents(
const registrySpinner = spinner(`Checking registry.`, {
silent: options.silent,
})?.start()
const tree = await registryResolveItemsTree(components, config)
const tree = await resolveRegistryTree(components, configWithDefaults(config))
if (!tree) {
registrySpinner?.fail()
@@ -357,9 +359,7 @@ async function shouldOverwriteCssVars(
components: z.infer<typeof registryItemSchema>["name"][],
config: z.infer<typeof configSchema>
) {
let result = await Promise.all(
components.map((component) => getRegistryItem(component, config))
)
const result = await getRegistryItems(components, config)
const payload = z.array(registryItemSchema).parse(result)
return payload.some(

View File

@@ -1,4 +1,4 @@
import { fetchRegistry } from "@/src/registry/api"
import { fetchRegistry } from "@/src/registry/fetcher"
import { spinner } from "@/src/utils/spinner"
import { execa } from "execa"
import fs from "fs-extra"
@@ -19,7 +19,7 @@ import { TEMPLATES, createProject } from "./create-project"
vi.mock("fs-extra")
vi.mock("execa")
vi.mock("prompts")
vi.mock("@/src/registry/api")
vi.mock("@/src/registry/fetcher")
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageManager: vi.fn().mockResolvedValue("npm"),
}))

View File

@@ -1,7 +1,7 @@
import os from "os"
import path from "path"
import { initOptionsSchema } from "@/src/commands/init"
import { fetchRegistry } from "@/src/registry/api"
import { fetchRegistry } from "@/src/registry/fetcher"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"

View File

@@ -1,3 +1,4 @@
import { RegistryError } from "@/src/registry/errors"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { z } from "zod"
@@ -15,6 +16,25 @@ export function handleError(error: unknown) {
process.exit(1)
}
if (error instanceof RegistryError) {
if (error.message) {
logger.error(error.cause ? "Error:" : "Message:")
logger.error(error.message)
}
if (error.cause) {
logger.error("\nMessage:")
logger.error(error.cause)
}
if (error.suggestion) {
logger.error("\nSuggestion:")
logger.error(error.suggestion)
}
logger.break()
process.exit(1)
}
if (error instanceof z.ZodError) {
logger.error("Validation failed:")
for (const [key, value] of Object.entries(error.flatten().fieldErrors)) {

View File

@@ -1,6 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { getRegistryItem } from "@/src/registry/api"
import { getRegistryItems } from "@/src/registry/api"
import { Config } from "@/src/utils/get-config"
export async function updateAppIndex(component: string, config: Config) {
@@ -10,7 +10,7 @@ export async function updateAppIndex(component: string, config: Config) {
return
}
const registryItem = await getRegistryItem(component, config)
const [registryItem] = await getRegistryItems([component], config)
if (
!registryItem?.meta?.importSpecifier ||
!registryItem?.meta?.moduleSpecifier

View File

@@ -1,559 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`registryResolveItemTree > should resolve index 1`] = `
{
"css": {},
"cssVars": {
"dark": {},
"light": {
"radius": "0.5rem",
},
"theme": {},
},
"dependencies": [
"clsx",
"tailwind-merge",
"@radix-ui/react-label",
"tailwindcss-animate",
"class-variance-authority",
"lucide-react",
],
"devDependencies": [],
"docs": "",
"files": [
{
"content": "import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
",
"path": "lib/utils.ts",
"target": "",
"type": "registry:lib",
},
{
"content": ""use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
",
"path": "ui/label.tsx",
"target": "",
"type": "registry:ui",
},
],
"tailwind": {
"config": {
"plugins": [
"require("tailwindcss-animate")",
],
"theme": {
"extend": {
"borderRadius": {
"lg": "var(--radius)",
"md": "calc(var(--radius) - 2px)",
"sm": "calc(var(--radius) - 4px)",
},
"colors": {},
},
},
},
},
}
`;
exports[`registryResolveItemTree > should resolve items tree 1`] = `
{
"css": {},
"cssVars": {},
"dependencies": [
"@radix-ui/react-slot",
],
"devDependencies": [],
"docs": "",
"files": [
{
"content": "import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
",
"path": "ui/button.tsx",
"target": "",
"type": "registry:ui",
},
],
"tailwind": {},
}
`;
exports[`registryResolveItemTree > should resolve multiple items tree 1`] = `
{
"css": {},
"cssVars": {},
"dependencies": [
"@radix-ui/react-slot",
"@radix-ui/react-dialog",
"cmdk",
],
"devDependencies": [],
"docs": "",
"files": [
{
"content": "import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
",
"path": "ui/button.tsx",
"target": "",
"type": "registry:ui",
},
{
"content": "import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
",
"path": "ui/input.tsx",
"target": "",
"type": "registry:ui",
},
{
"content": ""use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
",
"path": "ui/dialog.tsx",
"target": "",
"type": "registry:ui",
},
{
"content": ""use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/registry/default/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
",
"path": "ui/command.tsx",
"target": "",
"type": "registry:ui",
},
],
"tailwind": {},
}
`;

View File

@@ -97,13 +97,7 @@ describe("shadcn init - next-app", () => {
describe("shadcn init - vite-app", () => {
it("should init with custom alias and src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(
fixturePath,
["init", "--base-color=gray", "alert-dialog"],
{
debug: true,
}
)
await npxShadcn(fixturePath, ["init", "--base-color=gray", "alert-dialog"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")

View File

@@ -392,7 +392,7 @@ describe("registries", () => {
expect(output.stdout).toContain('Unknown registry "@non-existent"')
expect(output.stdout).toContain(
'"registries": {\n' +
' "@non-existent": "https://example.com/{name}.json"\n' +
' "@non-existent": "[URL_TO_REGISTRY]"\n' +
" }\n"
)
})
@@ -445,9 +445,7 @@ describe("registries", () => {
const output = await npxShadcn(fixturePath, ["add", "@acme/component"])
expect(output.stdout).toContain('Unknown registry "@acme"')
expect(output.stdout).toContain(
'"registries": {\n' +
' "@acme": "https://example.com/{name}.json"\n' +
" }\n"
'"registries": {\n' + ' "@acme": "[URL_TO_REGISTRY]"\n' + " }\n"
)
})
@@ -548,7 +546,7 @@ describe("registries", () => {
})
const output = await npxShadcn(fixturePath, ["add", "@two/two"])
expect(output.stdout).toContain("unknown registry @one")
expect(output.stdout).toContain('Unknown registry "@one"')
})
it("should show an error when adding multiple namespaced items with unconfigured registry", async () => {