From a426fea9410ade04c26bb4edfa5da813ecaddc17 Mon Sep 17 00:00:00 2001 From: shadcn Date: Sun, 10 Aug 2025 15:20:38 +0400 Subject: [PATCH] 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 --- .changeset/good-drinks-repeat.md | 5 + packages/shadcn/src/commands/add.ts | 25 +- packages/shadcn/src/commands/diff.ts | 4 +- packages/shadcn/src/commands/init.ts | 24 +- packages/shadcn/src/mcp/index.ts | 12 +- packages/shadcn/src/registry/api.test.ts | 534 +++-- packages/shadcn/src/registry/api.ts | 1118 +-------- packages/shadcn/src/registry/builder.test.ts | 27 + packages/shadcn/src/registry/builder.ts | 40 +- packages/shadcn/src/registry/config.test.ts | 221 ++ packages/shadcn/src/registry/config.ts | 37 + packages/shadcn/src/registry/constants.ts | 43 +- packages/shadcn/src/registry/context.ts | 3 +- packages/shadcn/src/registry/errors.ts | 238 ++ packages/shadcn/src/registry/fetcher.test.ts | 222 ++ packages/shadcn/src/registry/fetcher.ts | 156 ++ packages/shadcn/src/registry/index.ts | 23 +- packages/shadcn/src/registry/resolver.test.ts | 2031 ++++++++++++++++- packages/shadcn/src/registry/resolver.ts | 702 +++++- packages/shadcn/src/registry/utils.test.ts | 15 + packages/shadcn/src/registry/utils.ts | 3 +- .../shadcn/src/registry/validator.test.ts | 2 +- packages/shadcn/src/registry/validator.ts | 22 +- packages/shadcn/src/utils/add-components.ts | 12 +- .../shadcn/src/utils/create-project.test.ts | 4 +- packages/shadcn/src/utils/create-project.ts | 2 +- packages/shadcn/src/utils/handle-error.ts | 20 + packages/shadcn/src/utils/update-app-index.ts | 4 +- .../registry-resolve-items-tree.test.ts.snap | 559 ----- .../registry-resolve-items-tree.test.ts | 1738 -------------- packages/tests/src/tests/init.test.ts | 8 +- packages/tests/src/tests/registries.test.ts | 8 +- 32 files changed, 4201 insertions(+), 3661 deletions(-) create mode 100644 .changeset/good-drinks-repeat.md create mode 100644 packages/shadcn/src/registry/config.test.ts create mode 100644 packages/shadcn/src/registry/config.ts create mode 100644 packages/shadcn/src/registry/errors.ts create mode 100644 packages/shadcn/src/registry/fetcher.test.ts create mode 100644 packages/shadcn/src/registry/fetcher.ts delete mode 100644 packages/shadcn/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap delete mode 100644 packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts diff --git a/.changeset/good-drinks-repeat.md b/.changeset/good-drinks-repeat.md new file mode 100644 index 0000000000..8f14c990a2 --- /dev/null +++ b/.changeset/good-drinks-repeat.md @@ -0,0 +1,5 @@ +--- +"shadcn": major +--- + +update getRegistry, getRegistryItems and resolveRegistryItems apis diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts index e3c5e2241d..d930cb3037 100644 --- a/packages/shadcn/src/commands/add.ts +++ b/packages/shadcn/src/commands/add.ts @@ -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 ) { - const registryIndex = await getRegistryIndex() + const registryIndex = await getShadcnRegistryIndex() if (!registryIndex) { logger.break() handleError(new Error("Failed to fetch registry index.")) diff --git a/packages/shadcn/src/commands/diff.ts b/packages/shadcn/src/commands/diff.ts index fd35d7847d..25961ebbcc 100644 --- a/packages/shadcn/src/commands/diff.ts +++ b/packages/shadcn/src/commands/diff.ts @@ -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.")) diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index fb0a8cf345..18c2a7ef77 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -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[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() diff --git a/packages/shadcn/src/mcp/index.ts b/packages/shadcn/src/mcp/index.ts index ef8201b9f4..36016c5469 100644 --- a/packages/shadcn/src/mcp/index.ts +++ b/packages/shadcn/src/mcp/index.ts @@ -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 { diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index f8288815b4..799303e53b 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -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 + ) }) }) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index d929576240..9035212f17 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -1,67 +1,80 @@ -import { createHash } from "crypto" -import { promises as fs } from "fs" -import { homedir } from "os" import path from "path" import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder" +import { configWithDefaults } from "@/src/registry/config" +import { BASE_COLORS } from "@/src/registry/constants" import { clearRegistryContext, - getRegistryHeadersFromContext, + setRegistryHeaders, } from "@/src/registry/context" -import { parseRegistryAndItemFromString } from "@/src/registry/parser" -import { resolveRegistryItemsFromRegistries } from "@/src/registry/resolver" -import { deduplicateFilesByTarget, isLocalFile } 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 { highlighter } from "@/src/utils/highlighter" -import { logger } from "@/src/utils/logger" -import { buildTailwindThemeColorsFromCssVars } from "@/src/utils/updaters/update-tailwind-config" -import deepmerge from "deepmerge" -import { HttpsProxyAgent } from "https-proxy-agent" -import fetch from "node-fetch" -import { z } from "zod" - -import { REGISTRY_URL } from "./constants" +import { + RegistryNotFoundError, + RegistryParseError, +} from "@/src/registry/errors" +import { fetchRegistry } from "@/src/registry/fetcher" +import { + fetchRegistryItems, + resolveRegistryTree, +} from "@/src/registry/resolver" import { iconsSchema, registryBaseColorSchema, registryIndexSchema, registryItemSchema, - registryResolvedItemsTreeSchema, registrySchema, stylesSchema, -} from "./schema" +} from "@/src/registry/schema" +import { Config } from "@/src/utils/get-config" +import { handleError } from "@/src/utils/handle-error" +import { logger } from "@/src/utils/logger" +import { z } from "zod" -const agent = process.env.https_proxy - ? new HttpsProxyAgent(process.env.https_proxy) - : undefined +export async function getRegistry(name: `@${string}`, config?: Config) { + if (!name.endsWith("/registry")) { + name = `${name}/registry` + } -const registryCache = new Map>() + const urlAndHeaders = buildUrlAndHeadersForRegistryItem(name, config) -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 + if (!urlAndHeaders?.url) { + throw new RegistryNotFoundError(name) + } -export async function getRegistryIndex() { + // Set headers in context if provided + if (urlAndHeaders.headers && Object.keys(urlAndHeaders.headers).length > 0) { + setRegistryHeaders({ + [urlAndHeaders.url]: urlAndHeaders.headers, + }) + } + + const [result] = await fetchRegistry([urlAndHeaders.url]) + + try { + return registrySchema.parse(result) + } catch (error) { + throw new RegistryParseError(name, error) + } +} + +export async function getRegistryItems( + items: string[], + config?: Partial, + options: { useCache?: boolean } = {} +) { + clearRegistryContext() + + return fetchRegistryItems(items, configWithDefaults(config), options) +} + +export async function resolveRegistryItems( + items: string[], + config?: Partial, + options: { useCache?: boolean } = {} +) { + clearRegistryContext() + return resolveRegistryTree(items, configWithDefaults(config), options) +} + +export async function getShadcnRegistryIndex() { try { const [result] = await fetchRegistry(["index.json"]) @@ -94,84 +107,6 @@ export async function getRegistryIcons() { } } -export async function getRegistryItem( - name: string, - config?: Parameters[1], - options?: { - useCache?: boolean - } -) { - options = { - useCache: true, - ...options, - } - - try { - if (isLocalFile(name)) { - return await getLocalRegistryItem(name) - } - - if (isUrl(name)) { - const [result] = await fetchFromRegistry([name], config) - return result - } - - if (name.startsWith("@") && config?.registries) { - const [result] = await fetchFromRegistry([name], config, options) - return result - } - - // Handles regular component names. - const resolvedStyle = await getResolvedStyle(config) - const path = `styles/${resolvedStyle ?? "new-york-v4"}/${name}.json` - const [result] = await fetchFromRegistry([path], config, options) - return result - } catch (error) { - logger.break() - handleError(error) - return null - } -} - -async function getLocalRegistryItem(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) - - return registryItemSchema.parse(parsed) - } catch (error) { - logger.error(`Failed to read local registry file: ${filePath}`) - handleError(error) - return null - } -} - -export async function getRegistry( - name: `${string}/registry`, - config?: Parameters[1] -) { - try { - const results = await fetchFromRegistry([name], config, { useCache: false }) - - if (!results?.length) { - return null - } - - return registrySchema.parse(results[0]) - } catch (error) { - logger.break() - handleError(error) - return null - } -} - export async function getRegistryBaseColors() { return BASE_COLORS } @@ -186,6 +121,9 @@ export async function getRegistryBaseColor(baseColor: string) { } } +/** + * @deprecated This function is deprecated and will be removed in a future version. + */ export async function resolveTree( index: z.infer, names: string[] @@ -213,16 +151,20 @@ export async function resolveTree( ) } +/** + * @deprecated This function is deprecated and will be removed in a future version. + */ export async function fetchTree( style: string, tree: z.infer ) { try { const paths = tree.map((item) => `styles/${style}/${item.name}.json`) - const result = await fetchRegistry(paths) - return registryIndexSchema.parse(result) + const results = await fetchRegistry(paths) + return results.map((result) => registryItemSchema.parse(result)) } catch (error) { handleError(error) + return [] } } @@ -252,923 +194,3 @@ export async function getItemTargetPath( type ) } - -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 = getRegistryUrl(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) { - const errorMessages: { [key: number]: string } = { - 400: "Bad request", - 401: "Unauthorized", - 403: "Forbidden", - 404: "Not found", - 500: "Internal server error", - } - - let errorDetails = "" - try { - const result = await response.json() - if (result && typeof result === "object") { - const messages = [] - if ("error" in result && result.error) { - messages.push(`[${result.error}]: `) - } - if ("message" in result && result.message) { - messages.push(result.message) - } - if (messages.length > 0) { - errorDetails = `\n\nServer response: \n${messages.join("")}` - } - } - } catch { - // If we can't parse JSON, that's okay - } - - if (response.status === 401) { - throw new Error( - `You are not authorized to access the component at ${highlighter.info( - url - )}.\nIf this is a remote registry, you may need to authenticate.${errorDetails}` - ) - } - - if (response.status === 404) { - throw new Error( - `The component at ${highlighter.info( - url - )} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.${errorDetails}` - ) - } - - if (response.status === 403) { - throw new Error( - `You do not have access to the component at ${highlighter.info( - url - )}.\nIf this is a remote registry, you may need to authenticate or a token.${errorDetails}` - ) - } - - throw new Error( - `Failed to fetch from ${highlighter.info(url)}.\n${ - errorDetails || - response.statusText || - errorMessages[response.status] - }` - ) - } - - return response.json() - })() - - if (options.useCache) { - registryCache.set(url, fetchPromise) - } - return fetchPromise - }) - ) - - return results - } catch (error) { - logger.error("\n") - handleError(error) - return [] - } -} - -export function clearRegistryCache() { - registryCache.clear() -} - -async function getResolvedStyle( - config?: Pick & { - resolvedPaths: Pick - } -) { - if (!config) { - return undefined - } - - const tailwindVersion = await getProjectTailwindVersionFromConfig(config) - return tailwindVersion === "v4" && config.style === "new-york" - ? "new-york-v4" - : config.style -} - -export async function fetchFromRegistry( - items: `${string}/registry`[], - config?: Pick & { - resolvedPaths: Pick - }, - options?: { useCache?: boolean } -): Promise[]> - -export async function fetchFromRegistry( - items: string[], - config?: Pick & { - resolvedPaths: Pick - }, - options?: { useCache?: boolean } -): Promise[]> - -export async function fetchFromRegistry( - items: string[], - config?: Pick & { - resolvedPaths: Pick - }, - options: { useCache?: boolean } = {} -): Promise< - (z.infer | z.infer)[] -> { - clearRegistryContext() - - const resolvedStyle = await getResolvedStyle(config) - const configWithStyle = - config && resolvedStyle ? { ...config, style: resolvedStyle } : config - const paths = configWithStyle - ? resolveRegistryItemsFromRegistries(items, configWithStyle) - : items - - const results = await fetchRegistry(paths, options) - - return results.map((result, index) => { - const originalItem = items[index] - const resolvedItem = paths[index] - - if ( - originalItem.endsWith("/registry") || - resolvedItem.endsWith("/registry.json") - ) { - return registrySchema.parse(result) - } - - return registryItemSchema.parse(result) - }) -} - -async function resolveDependenciesRecursively( - dependencies: string[], - config?: Pick, - visited: Set = new Set() -) { - const items: z.infer[] = [] - 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 getRegistryItem(dep, config) - if (item) { - items.push(item) - if (item.registryDependencies) { - const nested = await resolveDependenciesRecursively( - item.registryDependencies, - config, - 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 Error( - `The items you're adding depend on unknown registry ${registry}. \nMake sure it is defined in components.json as follows:\n` + - `{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}` - ) - } - - // Let getRegistryItem handle the namespaced item with config - // This ensures proper authentication headers are used - const item = await getRegistryItem(dep, config) - if (item) { - items.push(item) - if (item.registryDependencies) { - const nested = await resolveDependenciesRecursively( - item.registryDependencies, - config, - visited - ) - items.push(...nested.items) - registryNames.push(...nested.registryNames) - } - } - } - // Handle regular component names - else { - registryNames.push(dep) - - if (config) { - try { - const item = await getRegistryItem(dep, config) - if (item && item.registryDependencies) { - const nested = await resolveDependenciesRecursively( - item.registryDependencies, - config, - 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 } -} - -export async function registryResolveItemsTree( - names: z.infer["name"][], - config: Config -) { - try { - // Check for namespaced items when no registries are configured - const namespacedItems = names.filter( - (name) => !isLocalFile(name) && !isUrl(name) && name.startsWith("@") - ) - - if (namespacedItems.length > 0 && !config?.registries) { - const { registry } = parseRegistryAndItemFromString(namespacedItems[0]) - 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}` - ) - } - - // Separate local files, URLs, and registry names. - const localFiles = names.filter((name) => isLocalFile(name)) - const urls = names.filter((name) => isUrl(name)) - const registryNames = names.filter( - (name) => !isLocalFile(name) && !isUrl(name) - ) - - let payload: z.infer[] = [] - - // Handle local files and URLs directly, resolving their dependencies individually. - let allDependencyItems: z.infer[] = [] - let allDependencyRegistryNames: string[] = [] - - const resolvedStyle = await getResolvedStyle(config) - const configWithStyle = - config && resolvedStyle ? { ...config, style: resolvedStyle } : config - - // Deduplicate exact URLs/paths before fetching - const uniqueLocalFiles = Array.from(new Set(localFiles)) - const uniqueUrls = Array.from(new Set(urls)) - - for (const localFile of uniqueLocalFiles) { - const item = await getRegistryItem(localFile) - if (item) { - // Add source tracking - const itemWithSource: z.infer = { - ...item, - _source: localFile, - } - 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) => - dep.startsWith("@") - ) - if (namespacedDeps.length > 0) { - const { registry } = parseRegistryAndItemFromString( - namespacedDeps[0] - ) - throw new Error( - `The items you're adding depend on unknown registry ${registry}. \nMake sure it is defined in components.json as follows:\n` + - `{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}` - ) - } - } else { - resolvedDependencies = resolveRegistryItemsFromRegistries( - item.registryDependencies, - configWithStyle - ) - } - - const { items, registryNames } = await resolveDependenciesRecursively( - resolvedDependencies, - config, - new Set() - ) - allDependencyItems.push(...items) - allDependencyRegistryNames.push(...registryNames) - } - } - } - - for (const url of uniqueUrls) { - const item = await getRegistryItem(url, config) - if (item) { - // Add source tracking - const itemWithSource: z.infer = { - ...item, - _source: url, - } - 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) => - dep.startsWith("@") - ) - if (namespacedDeps.length > 0) { - const { registry } = parseRegistryAndItemFromString( - namespacedDeps[0] - ) - throw new Error( - `The items you're adding depend on unknown registry ${registry}. \nMake sure it is defined in components.json as follows:\n` + - `{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}` - ) - } - } else { - resolvedDependencies = resolveRegistryItemsFromRegistries( - item.registryDependencies, - configWithStyle - ) - } - - const { items, registryNames } = await resolveDependenciesRecursively( - resolvedDependencies, - config, - new Set() - ) - - allDependencyItems.push(...items) - allDependencyRegistryNames.push(...registryNames) - } - } - } - - payload.push(...allDependencyItems) - - // Handle registry names using the new fetchFromRegistry logic. - const allRegistryNames = [...registryNames, ...allDependencyRegistryNames] - if (allRegistryNames.length > 0) { - // Separate namespaced and non-namespaced items - const nonNamespacedItems = allRegistryNames.filter( - (name) => !name.startsWith("@") - ) - const namespacedItems = allRegistryNames.filter((name) => - name.startsWith("@") - ) - - // Handle namespaced items directly with fetchFromRegistry - if (namespacedItems.length > 0) { - const results = await fetchFromRegistry(namespacedItems, config) - const namespacedPayload = results as z.infer< - typeof registryItemSchema - >[] - - // Add source tracking for namespaced items - const itemsWithSource: z.infer[] = - namespacedPayload.map((item, index) => ({ - ...item, - _source: namespacedItems[index], - })) - - payload.push(...itemsWithSource) - - // Process dependencies of namespaced items - for (const item of namespacedPayload) { - if (item.registryDependencies) { - const { items: depItems, registryNames: depNames } = - await resolveDependenciesRecursively( - item.registryDependencies, - config, - new Set([...namespacedItems]) - ) - payload.push(...depItems) - - // Add any non-namespaced dependencies to be processed - const nonNamespacedDeps = depNames.filter( - (name) => !name.startsWith("@") - ) - nonNamespacedItems.push(...nonNamespacedDeps) - } - } - } - - // For non-namespaced items, we need the index and style resolution - if (nonNamespacedItems.length > 0) { - const index = await getRegistryIndex() - if (!index && payload.length === 0) { - return null - } - - if (index) { - // Remove duplicates from non-namespaced items - const uniqueNonNamespaced = Array.from(new Set(nonNamespacedItems)) - - // If we're resolving the index, we want it to go first - if (uniqueNonNamespaced.includes("index")) { - uniqueNonNamespaced.unshift("index") - } - - // Resolve non-namespaced items through the existing flow - let registryItems = await resolveRegistryItems( - uniqueNonNamespaced, - config - ) - let result = await fetchRegistry(registryItems) - 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 (allRegistryNames.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, 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 resolveRegistryDependencies(url: string, config: Config) { - if (isUrl(url)) { - return [url] - } - - const { registryNames } = await resolveDependenciesRecursively( - [url], - config, - new Set() - ) - - const style = config.resolvedPaths?.cwd - ? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style) - : config.style - - const urls = registryNames.map((name) => - getRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`) - ) - - return Array.from(new Set(urls)) -} - -export 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 - - 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 getRegistryUrl(path: string) { - if (isUrl(path)) { - // 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(path) - if (url.pathname.match(/\/chat\/b\//) && !url.pathname.endsWith("/json")) { - url.pathname = `${url.pathname}/json` - } - - return url.toString() - } - - return `${REGISTRY_URL}/${path}` -} - -export function isUrl(path: string) { - try { - new URL(path) - return true - } catch (error) { - return false - } -} - -// TODO: We're double-fetching here. Use a cache. -export async function resolveRegistryItems(names: string[], config: Config) { - let registryDependencies: string[] = [] - - const registryNames = names.filter( - (name) => !isLocalFile(name) && !isUrl(name) - ) - - const resolvedStyle = await getResolvedStyle(config) - for (const name of registryNames) { - let resolvedName = name - if (config) { - try { - const configWithStyle = - config && resolvedStyle ? { ...config, style: resolvedStyle } : config - - const resolved = buildUrlAndHeadersForRegistryItem( - name, - configWithStyle - ) - if (resolved) { - resolvedName = resolved.url - } - } catch (error) {} - } - - const itemRegistryDependencies = await resolveRegistryDependencies( - resolvedName, - config - ) - registryDependencies.push(...itemRegistryDependencies) - } - - return Array.from(new Set(registryDependencies)) -} - -export function getRegistryTypeAliasMap() { - return new Map([ - ["registry:ui", "ui"], - ["registry:lib", "lib"], - ["registry:hook", "hooks"], - ["registry:block", "components"], - ["registry:component", "components"], - ]) -} - -// Track a dependency and its parent. -export function getRegistryParentMap( - registryItems: z.infer[] -) { - const map = new Map>() - registryItems.forEach((item) => { - if (!item.registryDependencies) { - return - } - - item.registryDependencies.forEach((dependency) => { - map.set(dependency, item) - }) - }) - return map -} - -const registryItemWithSourceSchema = registryItemSchema.extend({ - _source: z.string().optional(), -}) - -function computeItemHash( - item: Pick, "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[], - sourceMap: Map, string> -) { - const itemMap = new Map>() - const hashToItem = new Map>() - const inDegree = new Map() - const adjacencyList = new Map() - - 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() - 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[] = [] - - 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 -} diff --git a/packages/shadcn/src/registry/builder.test.ts b/packages/shadcn/src/registry/builder.test.ts index 099fdb2732..b2b71a8e9b 100644 --- a/packages/shadcn/src/registry/builder.test.ts +++ b/packages/shadcn/src/registry/builder.test.ts @@ -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` + ) + }) +}) diff --git a/packages/shadcn/src/registry/builder.ts b/packages/shadcn/src/registry/builder.ts index a9d1e497b8..7becd4b0c0 100644 --- a/packages/shadcn/src/registry/builder.ts +++ b/packages/shadcn/src/registry/builder.ts @@ -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, "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, - config?: Pick, "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}` +} diff --git a/packages/shadcn/src/registry/config.test.ts b/packages/shadcn/src/registry/config.test.ts new file mode 100644 index 0000000000..175cda7af5 --- /dev/null +++ b/packages/shadcn/src/registry/config.test.ts @@ -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() + }) +}) diff --git a/packages/shadcn/src/registry/config.ts b/packages/shadcn/src/registry/config.ts new file mode 100644 index 0000000000..e4159ddc77 --- /dev/null +++ b/packages/shadcn/src/registry/config.ts @@ -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) { + 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) { + 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 }, + }) + ) +} diff --git a/packages/shadcn/src/registry/constants.ts b/packages/shadcn/src/registry/constants.ts index ab26eb8b9a..31bc0acee6 100644 --- a/packages/shadcn/src/registry/constants.ts +++ b/packages/shadcn/src/registry/constants.ts @@ -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 = { "@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.", + }, +] diff --git a/packages/shadcn/src/registry/context.ts b/packages/shadcn/src/registry/context.ts index cd6db42721..9de7d3e417 100644 --- a/packages/shadcn/src/registry/context.ts +++ b/packages/shadcn/src/registry/context.ts @@ -9,7 +9,8 @@ let context: RegistryContext = { export function setRegistryHeaders( headers: Record> ) { - context.headers = headers + // Merge new headers with existing ones to preserve headers for nested dependencies + context.headers = { ...context.headers, ...headers } } export function getRegistryHeadersFromContext( diff --git a/packages/shadcn/src/registry/errors.ts b/packages/shadcn/src/registry/errors.ts new file mode 100644 index 0000000000..d1dc1d0295 --- /dev/null +++ b/packages/shadcn/src/registry/errors.ts @@ -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 + public readonly suggestion?: string + public readonly timestamp: Date + public readonly cause?: unknown + + constructor( + message: string, + options: { + code?: RegistryErrorCode + statusCode?: number + cause?: unknown + context?: Record + 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" + } +} diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts new file mode 100644 index 0000000000..7061e53e1a --- /dev/null +++ b/packages/shadcn/src/registry/fetcher.test.ts @@ -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" }) + }) +}) diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts new file mode 100644 index 0000000000..e3c2444990 --- /dev/null +++ b/packages/shadcn/src/registry/fetcher.ts @@ -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>() + +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) + } +} diff --git a/packages/shadcn/src/registry/index.ts b/packages/shadcn/src/registry/index.ts index 31911a6a5a..3958046a1f 100644 --- a/packages/shadcn/src/registry/index.ts +++ b/packages/shadcn/src/registry/index.ts @@ -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" diff --git a/packages/shadcn/src/registry/resolver.test.ts b/packages/shadcn/src/registry/resolver.test.ts index 27a56d5b50..7831337c7e 100644 --- a/packages/shadcn/src/registry/resolver.test.ts +++ b/packages/shadcn/src/registry/resolver.test.ts @@ -1,14 +1,49 @@ /* eslint-disable turbo/no-undeclared-env-vars */ -import { beforeEach, describe, expect, it, vi } from "vitest" +import { promises as fs } from "fs" +import { tmpdir } from "os" +import path from "path" +import { HttpResponse, http } from "msw" +import { setupServer } from "msw/node" +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + test, + vi, +} from "vitest" +import { createRegistryServer } from "../../../tests/src/utils/registry" import { setRegistryHeaders } from "./context" -import { resolveRegistryItemsFromRegistries } from "./resolver" +import { + resolveRegistryItemsFromRegistries, + resolveRegistryTree, +} from "./resolver" -// Mock the context module vi.mock("./context", () => ({ setRegistryHeaders: vi.fn(), + clearRegistryContext: vi.fn(), + getRegistryHeadersFromContext: vi.fn(() => ({})), })) +vi.mock("@/src/utils/handle-error", () => ({ + handleError: vi.fn((error) => { + console.error("Test error:", error) + }), +})) + +vi.mock("@/src/utils/logger", () => ({ + logger: { + error: vi.fn(), + break: vi.fn(), + log: vi.fn(), + }, +})) + +// Note: Individual tests will create their own MSW servers using createRegistryServer + describe("resolveRegistryItemsFromRegistries", () => { beforeEach(() => { vi.clearAllMocks() @@ -23,7 +58,9 @@ describe("resolveRegistryItemsFromRegistries", () => { }) it("should return empty array for empty input with no registries", () => { - const result = resolveRegistryItemsFromRegistries([], undefined) + const result = resolveRegistryItemsFromRegistries([], { + registries: {}, + } as any) expect(result).toEqual([]) expect(setRegistryHeaders).toHaveBeenCalledWith({}) }) @@ -343,3 +380,1989 @@ describe("resolveRegistryItemsFromRegistries", () => { expect(setRegistryHeaders).toHaveBeenCalledWith({}) }) }) + +describe("resolveRegistryItems with URL dependencies", () => { + it("should resolve URL dependencies from local files", async () => { + const dependencyUrl = "https://example.com/dependency.json" + + // Create a mock server for the URL dependency + const mockServer = setupServer( + 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", + }, + ], + }) + }) + ) + + mockServer.listen({ onUnhandledRequest: "bypass" }) + + 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", + }, + ], + } + + await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2)) + + try { + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + resolvedPaths: { cwd: process.cwd() }, + } as any + + const result = await resolveRegistryTree([tempFile], mockConfig) + + 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") + } finally { + // Clean up + await fs.unlink(tempFile) + await fs.rmdir(tempDir) + mockServer.close() + } + }) + + it("should resolve namespace syntax in registryDependencies", async () => { + // Mock a namespace registry endpoint + const namespaceUrl = "https://custom-registry.com/custom-component.json" + + const mockServer = setupServer( + 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", + }, + ], + }) + }) + ) + + mockServer.listen({ onUnhandledRequest: "bypass" }) + + 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", + }, + ], + } + + await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2)) + + 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 + + const result = await resolveRegistryTree([tempFile], mockConfig) + + expect(result).toBeDefined() + expect(result?.files).toBeDefined() + + 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) + } finally { + // Clean up + await fs.unlink(tempFile) + await fs.rmdir(tempDir) + mockServer.close() + } + }) +}) + +describe("resolveRegistryTree - dependency ordering", () => { + let customRegistry: Awaited> + + beforeAll(async () => { + // Create a custom registry server for testing dependency ordering + customRegistry = await createRegistryServer( + [ + { + name: "base-component", + type: "registry:ui", + files: [ + { + path: "components/ui/base.tsx", + content: "export const Base = () =>
Base Component
", + type: "registry:ui", + }, + ], + cssVars: { + light: { base: "#111111" }, + }, + }, + { + name: "extended-component", + type: "registry:ui", + registryDependencies: ["http://localhost:4447/r/base-component.json"], + files: [ + { + path: "components/ui/extended.tsx", + content: + "export const Extended = () =>
Extended Component
", + type: "registry:ui", + }, + ], + cssVars: { + light: { extended: "#222222" }, + }, + }, + { + name: "deep-component", + type: "registry:ui", + registryDependencies: [ + "http://localhost:4447/r/extended-component.json", + ], + files: [ + { + path: "components/ui/deep.tsx", + content: "export const Deep = () =>
Deep Component
", + type: "registry:ui", + }, + ], + }, + // Circular dependency test + { + name: "circular-a", + type: "registry:ui", + registryDependencies: ["http://localhost:4447/r/circular-b.json"], + files: [ + { + path: "components/ui/circular-a.tsx", + content: "export const CircularA = () =>
A
", + type: "registry:ui", + }, + ], + }, + { + name: "circular-b", + type: "registry:ui", + registryDependencies: ["http://localhost:4447/r/circular-a.json"], + files: [ + { + path: "components/ui/circular-b.tsx", + content: "export const CircularB = () =>
B
", + type: "registry:ui", + }, + ], + }, + ], + { port: 4447 } + ) + + await customRegistry.start() + }) + + afterAll(async () => { + await customRegistry.stop() + }) + + test("should order dependencies before items that depend on them", async () => { + const result = await resolveRegistryTree( + ["http://localhost:4447/r/extended-component.json"], + { + style: "default", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + ) + + expect(result).toBeTruthy() + expect(result?.files).toHaveLength(2) + + // Base component should come first. + expect(result?.files?.[0]).toMatchObject({ + path: "components/ui/base.tsx", + content: expect.stringContaining("Base Component"), + }) + + // Extended component should come second. + expect(result?.files?.[1]).toMatchObject({ + path: "components/ui/extended.tsx", + content: expect.stringContaining("Extended Component"), + }) + + expect(result?.cssVars?.light).toMatchObject({ + base: "#111111", + extended: "#222222", + }) + }) + + test("should handle complex dependency chains", async () => { + const result = await resolveRegistryTree( + ["http://localhost:4447/r/deep-component.json"], + { + style: "new-york", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + ) + + expect(result).toBeTruthy() + expect(result?.files).toHaveLength(3) + + // Order should be: base -> extended -> deep. + expect(result?.files?.[0].content).toContain("Base Component") + expect(result?.files?.[1].content).toContain("Extended Component") + expect(result?.files?.[2].content).toContain("Deep Component") + }) + + test("should handle circular dependencies gracefully", async () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + const result = await resolveRegistryTree( + [ + "http://localhost:4447/r/circular-a.json", + "http://localhost:4447/r/circular-b.json", + ], + { + style: "new-york", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + ) + + expect(result).toBeTruthy() + + // With circular dependencies, we might get duplicates in the files array + // but we should have at least one of each circular item + const hasCircularA = result?.files?.some( + (f) => f.path === "components/ui/circular-a.tsx" + ) + const hasCircularB = result?.files?.some( + (f) => f.path === "components/ui/circular-b.tsx" + ) + expect(hasCircularA).toBe(true) + expect(hasCircularB).toBe(true) + + // Should have logged a warning about circular dependency + expect(consoleSpy).toHaveBeenCalledWith( + "Circular dependency detected in registry items" + ) + + consoleSpy.mockRestore() + }) + + test("should handle exact duplicate URLs by including only once", async () => { + const result = await resolveRegistryTree( + [ + "http://localhost:4447/r/base-component.json", + "http://localhost:4447/r/base-component.json", + ], + { + style: "new-york", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + ) + + expect(result?.files).toHaveLength(1) + expect(result?.files?.[0].path).toBe("components/ui/base.tsx") + }) + + test("should handle items with same name from different registries", async () => { + const secondRegistry = await createRegistryServer( + [ + { + name: "base-component", + type: "registry:ui", + files: [ + { + path: "components/ui/base-alt.tsx", + content: + "export const BaseAlt = () =>
Alternative Base Component
", + type: "registry:ui", + }, + ], + cssVars: { + light: { altBase: "#999999" }, + }, + }, + ], + { port: 4448 } + ) + + await secondRegistry.start() + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + const result = await resolveRegistryTree( + [ + "http://localhost:4447/r/base-component.json", + "http://localhost:4448/r/base-component.json", + ], + { + style: "default", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + ) + + expect(result?.files).toHaveLength(2) + + const filePaths = result?.files?.map((f) => f.path).sort() + expect(filePaths).toEqual([ + "components/ui/base-alt.tsx", + "components/ui/base.tsx", + ]) + + expect(result?.cssVars?.light).toHaveProperty("base", "#111111") + expect(result?.cssVars?.light).toHaveProperty("altBase", "#999999") + + consoleSpy.mockRestore() + await secondRegistry.stop() + }) + + test("should correctly resolve dependencies when multiple items have same dependency name", async () => { + const result = await resolveRegistryTree( + ["http://localhost:4447/r/extended-component.json"], + { + style: "new-york", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + ) + + expect(result?.files).toHaveLength(2) + expect(result?.files?.[0].path).toBe("components/ui/base.tsx") + expect(result?.files?.[1].path).toBe("components/ui/extended.tsx") + }) +}) + +describe("resolveRegistryTree - potential target conflicts", async () => { + const exampleRegistry = await createRegistryServer( + [ + { + name: "button-variant-a", + type: "registry:ui", + files: [ + { + path: "ui/button.tsx", + content: "export const Button = () => ", + type: "registry:ui", + }, + { + path: "ui/button.styles.css", + content: ".button-a { color: red; }", + type: "registry:ui", + }, + ], + }, + { + name: "button-variant-b", + type: "registry:ui", + files: [ + { + path: "ui/button.tsx", + content: "export const Button = () => ", + type: "registry:ui", + }, + { + path: "components/button/index.tsx", + content: "export { Button } from './button'", + type: "registry:ui", + }, + ], + }, + { + name: "component-with-explicit-target", + type: "registry:ui", + files: [ + { + path: "custom/component.tsx", + content: "export const Component = () =>
Custom
", + type: "registry:ui", + target: "components/ui/button.tsx", + }, + ], + }, + { + name: "lib-and-ui-conflict", + type: "registry:ui", + registryDependencies: ["http://localhost:4449/r/lib-utils.json"], + files: [ + { + path: "ui/utils.ts", + content: "export const uiUtils = () => {}", + type: "registry:ui", + }, + ], + }, + { + name: "lib-utils", + type: "registry:lib", + files: [ + { + path: "lib/utils.ts", + content: "export const libUtils = () => {}", + type: "registry:lib", + }, + { + path: "utils.ts", + content: "export const utils = () => {}", + type: "registry:lib", + }, + ], + }, + ], + { port: 4449 } + ) + + const sharedConfig = { + style: "default", + rsc: false, + tsx: false, + aliases: { + components: "./components", + utils: "./lib/utils", + ui: "./components/ui", + }, + tailwind: { + config: "./tailwind.config.js", + baseColor: "neutral", + css: "globals.css", + cssVariables: false, + }, + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "./tailwind.config.js", + tailwindCss: "./globals.css", + utils: "./lib/utils", + components: "./components", + lib: "./lib", + hooks: "./hooks", + ui: "./components/ui", + }, + } + + beforeAll(async () => { + await exampleRegistry.start() + }) + + afterAll(async () => { + await exampleRegistry.stop() + }) + + test("should deduplicate files with same resolved target (last wins) and preserve order", async () => { + expect( + await resolveRegistryTree( + [ + "http://localhost:4449/r/button-variant-a.json", + "http://localhost:4449/r/button-variant-b.json", + ], + sharedConfig + ) + ).toMatchInlineSnapshot(` + { + "css": {}, + "cssVars": {}, + "dependencies": [], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": "export const Button = () => ", + "path": "ui/button.tsx", + "type": "registry:ui", + }, + { + "content": ".button-a { color: red; }", + "path": "ui/button.styles.css", + "type": "registry:ui", + }, + { + "content": "export { Button } from './button'", + "path": "components/button/index.tsx", + "type": "registry:ui", + }, + ], + "tailwind": {}, + } + `) + + expect( + await resolveRegistryTree( + [ + "http://localhost:4449/r/button-variant-b.json", + "http://localhost:4449/r/button-variant-a.json", + ], + sharedConfig + ) + ).toMatchInlineSnapshot(` + { + "css": {}, + "cssVars": {}, + "dependencies": [], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": "export const Button = () => ", + "path": "ui/button.tsx", + "type": "registry:ui", + }, + { + "content": "export { Button } from './button'", + "path": "components/button/index.tsx", + "type": "registry:ui", + }, + { + "content": ".button-a { color: red; }", + "path": "ui/button.styles.css", + "type": "registry:ui", + }, + ], + "tailwind": {}, + } + `) + }) + + test("should handle explicit target overrides", async () => { + expect( + await resolveRegistryTree( + [ + "http://localhost:4449/r/button-variant-a.json", + "http://localhost:4449/r/component-with-explicit-target.json", + ], + sharedConfig + ) + ).toMatchInlineSnapshot(` + { + "css": {}, + "cssVars": {}, + "dependencies": [], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": "export const Button = () => ", + "path": "ui/button.tsx", + "type": "registry:ui", + }, + { + "content": ".button-a { color: red; }", + "path": "ui/button.styles.css", + "type": "registry:ui", + }, + { + "content": "export const Component = () =>
Custom
", + "path": "custom/component.tsx", + "target": "components/ui/button.tsx", + "type": "registry:ui", + }, + ], + "tailwind": {}, + } + `) + }) + + test("should preserve files with different types even if paths are similar", async () => { + expect( + await resolveRegistryTree( + ["http://localhost:4449/r/lib-and-ui-conflict.json"], + sharedConfig + ) + ).toMatchInlineSnapshot(` + { + "css": {}, + "cssVars": {}, + "dependencies": [], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": "export const utils = () => {}", + "path": "utils.ts", + "type": "registry:lib", + }, + { + "content": "export const uiUtils = () => {}", + "path": "ui/utils.ts", + "type": "registry:ui", + }, + ], + "tailwind": {}, + } + `) + }) + + test("should handle complex nested paths and deduplicate correctly", async () => { + // Create a custom registry with nested paths. + const nestedRegistry = await createRegistryServer( + [ + { + name: "nested-a", + type: "registry:ui", + files: [ + { + path: "ui/forms/input.tsx", + content: "export const Input = () => ", + type: "registry:ui", + }, + { + path: "ui/forms/button.tsx", + content: + "export const FormButton = () => ", + type: "registry:ui", + }, + ], + }, + { + name: "nested-b", + type: "registry:ui", + files: [ + { + path: "ui/forms/button.tsx", + content: + "export const FormButton = () => ", + type: "registry:ui", + }, + { + path: "ui/forms/select.tsx", + content: "export const Select = () => ", + "path": "ui/forms/input.tsx", + "type": "registry:ui", + }, + { + "content": "export const FormButton = () => ", + "path": "ui/forms/button.tsx", + "type": "registry:ui", + }, + { + "content": "export const Select = () => - ) - } -) -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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Command.displayName = CommandPrimitive.displayName - -const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - - - - {children} - - - - ) -} - -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)) - -CommandInput.displayName = CommandPrimitive.Input.displayName - -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandList.displayName = CommandPrimitive.List.displayName - -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)) - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandGroup.displayName = CommandPrimitive.Group.displayName - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -CommandSeparator.displayName = CommandPrimitive.Separator.displayName - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandItem.displayName = CommandPrimitive.Item.displayName - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ) -} -CommandShortcut.displayName = "CommandShortcut" - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -} -", - "path": "ui/command.tsx", - "target": "", - "type": "registry:ui", - }, - ], - "tailwind": {}, -} -`; diff --git a/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts b/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts deleted file mode 100644 index 07378f442d..0000000000 --- a/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts +++ /dev/null @@ -1,1738 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test, vi } from "vitest" - -import { createRegistryServer } from "../../../../tests/src/utils/registry" -import { registryResolveItemsTree } from "../../../src/registry/api" - -describe("registryResolveItemTree", () => { - test("should resolve items tree", async () => { - expect( - await registryResolveItemsTree(["button"], { - style: "new-york", - tailwind: { - baseColor: "stone", - }, - }) - ).toMatchSnapshot() - }) - - test("should resolve multiple items tree", async () => { - expect( - await registryResolveItemsTree(["button", "input", "command"], { - style: "default", - tailwind: { - baseColor: "zinc", - }, - }) - ).toMatchSnapshot() - }) - - test("should resolve index", async () => { - expect( - await registryResolveItemsTree(["index", "label"], { - style: "default", - tailwind: { - baseColor: "zinc", - }, - }) - ).toMatchSnapshot() - }) -}) - -describe("registryResolveItemTree - dependency ordering", () => { - let customRegistry: Awaited> - - beforeAll(async () => { - // Create a custom registry server for testing dependency ordering - customRegistry = await createRegistryServer( - [ - { - name: "base-component", - type: "registry:ui", - files: [ - { - path: "components/ui/base.tsx", - content: "export const Base = () =>
Base Component
", - type: "registry:ui", - }, - ], - cssVars: { - light: { base: "#111111" }, - }, - }, - { - name: "extended-component", - type: "registry:ui", - registryDependencies: ["http://localhost:4447/r/base-component.json"], - files: [ - { - path: "components/ui/extended.tsx", - content: - "export const Extended = () =>
Extended Component
", - type: "registry:ui", - }, - ], - cssVars: { - light: { extended: "#222222" }, - }, - }, - { - name: "deep-component", - type: "registry:ui", - registryDependencies: [ - "http://localhost:4447/r/extended-component.json", - ], - files: [ - { - path: "components/ui/deep.tsx", - content: "export const Deep = () =>
Deep Component
", - type: "registry:ui", - }, - ], - }, - // Circular dependency test - { - name: "circular-a", - type: "registry:ui", - registryDependencies: ["http://localhost:4447/r/circular-b.json"], - files: [ - { - path: "components/ui/circular-a.tsx", - content: "export const CircularA = () =>
A
", - type: "registry:ui", - }, - ], - }, - { - name: "circular-b", - type: "registry:ui", - registryDependencies: ["http://localhost:4447/r/circular-a.json"], - files: [ - { - path: "components/ui/circular-b.tsx", - content: "export const CircularB = () =>
B
", - type: "registry:ui", - }, - ], - }, - ], - { port: 4447 } - ) - - await customRegistry.start() - }) - - afterAll(async () => { - await customRegistry.stop() - }) - - test("should order dependencies before items that depend on them", async () => { - const result = await registryResolveItemsTree( - ["http://localhost:4447/r/extended-component.json"], - { - style: "default", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - ) - - expect(result).toBeTruthy() - expect(result?.files).toHaveLength(2) - - // Base component should come first. - expect(result?.files?.[0]).toMatchObject({ - path: "components/ui/base.tsx", - content: expect.stringContaining("Base Component"), - }) - - // Extended component should come second. - expect(result?.files?.[1]).toMatchObject({ - path: "components/ui/extended.tsx", - content: expect.stringContaining("Extended Component"), - }) - - expect(result?.cssVars?.light).toMatchObject({ - base: "#111111", - extended: "#222222", - }) - }) - - test("should handle complex dependency chains", async () => { - const result = await registryResolveItemsTree( - ["http://localhost:4447/r/deep-component.json"], - { - style: "new-york", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - ) - - expect(result).toBeTruthy() - expect(result?.files).toHaveLength(3) - - // Order should be: base -> extended -> deep. - expect(result?.files?.[0].content).toContain("Base Component") - expect(result?.files?.[1].content).toContain("Extended Component") - expect(result?.files?.[2].content).toContain("Deep Component") - }) - - test("should handle circular dependencies gracefully", async () => { - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - - const result = await registryResolveItemsTree( - [ - "http://localhost:4447/r/circular-a.json", - "http://localhost:4447/r/circular-b.json", - ], - { - style: "new-york", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - ) - - expect(result).toBeTruthy() - - // With circular dependencies, we might get duplicates in the files array - // but we should have at least one of each circular item - const hasCircularA = result?.files?.some( - (f) => f.path === "components/ui/circular-a.tsx" - ) - const hasCircularB = result?.files?.some( - (f) => f.path === "components/ui/circular-b.tsx" - ) - expect(hasCircularA).toBe(true) - expect(hasCircularB).toBe(true) - - // Should have logged a warning about circular dependency - expect(consoleSpy).toHaveBeenCalledWith( - "Circular dependency detected in registry items" - ) - - consoleSpy.mockRestore() - }) - - test("should handle exact duplicate URLs by including only once", async () => { - const result = await registryResolveItemsTree( - [ - "http://localhost:4447/r/base-component.json", - "http://localhost:4447/r/base-component.json", - ], - { - style: "new-york", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - ) - - expect(result?.files).toHaveLength(1) - expect(result?.files?.[0].path).toBe("components/ui/base.tsx") - }) - - test("should handle items with same name from different registries", async () => { - const secondRegistry = await createRegistryServer( - [ - { - name: "base-component", - type: "registry:ui", - files: [ - { - path: "components/ui/base-alt.tsx", - content: - "export const BaseAlt = () =>
Alternative Base Component
", - type: "registry:ui", - }, - ], - cssVars: { - light: { altBase: "#999999" }, - }, - }, - ], - { port: 4448 } - ) - - await secondRegistry.start() - - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - - const result = await registryResolveItemsTree( - [ - "http://localhost:4447/r/base-component.json", - "http://localhost:4448/r/base-component.json", - ], - { - style: "default", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - ) - - expect(result?.files).toHaveLength(2) - - const filePaths = result?.files?.map((f) => f.path).sort() - expect(filePaths).toEqual([ - "components/ui/base-alt.tsx", - "components/ui/base.tsx", - ]) - - expect(result?.cssVars?.light).toHaveProperty("base", "#111111") - expect(result?.cssVars?.light).toHaveProperty("altBase", "#999999") - - consoleSpy.mockRestore() - await secondRegistry.stop() - }) - - test("should correctly resolve dependencies when multiple items have same dependency name", async () => { - const result = await registryResolveItemsTree( - ["http://localhost:4447/r/extended-component.json"], - { - style: "new-york", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - ) - - expect(result?.files).toHaveLength(2) - expect(result?.files?.[0].path).toBe("components/ui/base.tsx") - expect(result?.files?.[1].path).toBe("components/ui/extended.tsx") - }) -}) - -describe("registryResolveItemTree - potential target conflicts", async () => { - const exampleRegistry = await createRegistryServer( - [ - { - name: "button-variant-a", - type: "registry:ui", - files: [ - { - path: "ui/button.tsx", - content: "export const Button = () => ", - type: "registry:ui", - }, - { - path: "ui/button.styles.css", - content: ".button-a { color: red; }", - type: "registry:ui", - }, - ], - }, - { - name: "button-variant-b", - type: "registry:ui", - files: [ - { - path: "ui/button.tsx", - content: "export const Button = () => ", - type: "registry:ui", - }, - { - path: "components/button/index.tsx", - content: "export { Button } from './button'", - type: "registry:ui", - }, - ], - }, - { - name: "component-with-explicit-target", - type: "registry:ui", - files: [ - { - path: "custom/component.tsx", - content: "export const Component = () =>
Custom
", - type: "registry:ui", - target: "components/ui/button.tsx", - }, - ], - }, - { - name: "lib-and-ui-conflict", - type: "registry:ui", - registryDependencies: ["http://localhost:4449/r/lib-utils.json"], - files: [ - { - path: "ui/utils.ts", - content: "export const uiUtils = () => {}", - type: "registry:ui", - }, - ], - }, - { - name: "lib-utils", - type: "registry:lib", - files: [ - { - path: "lib/utils.ts", - content: "export const libUtils = () => {}", - type: "registry:lib", - }, - { - path: "utils.ts", - content: "export const utils = () => {}", - type: "registry:lib", - }, - ], - }, - ], - { port: 4449 } - ) - - const sharedConfig = { - style: "default", - tailwind: { baseColor: "neutral" }, - resolvedPaths: { - cwd: process.cwd(), - tailwindConfig: "./tailwind.config.js", - tailwindCss: "./globals.css", - utils: "./lib/utils", - components: "./components", - lib: "./lib", - hooks: "./hooks", - ui: "./components/ui", - }, - } - - beforeAll(async () => { - await exampleRegistry.start() - }) - - afterAll(async () => { - await exampleRegistry.stop() - }) - - test("should deduplicate files with same resolved target (last wins) and preserve order", async () => { - expect( - await registryResolveItemsTree( - [ - "http://localhost:4449/r/button-variant-a.json", - "http://localhost:4449/r/button-variant-b.json", - ], - sharedConfig - ) - ).toMatchInlineSnapshot(` - { - "css": {}, - "cssVars": {}, - "dependencies": [], - "devDependencies": [], - "docs": "", - "files": [ - { - "content": "export const Button = () => ", - "path": "ui/button.tsx", - "type": "registry:ui", - }, - { - "content": ".button-a { color: red; }", - "path": "ui/button.styles.css", - "type": "registry:ui", - }, - { - "content": "export { Button } from './button'", - "path": "components/button/index.tsx", - "type": "registry:ui", - }, - ], - "tailwind": {}, - } - `) - - expect( - await registryResolveItemsTree( - [ - "http://localhost:4449/r/button-variant-b.json", - "http://localhost:4449/r/button-variant-a.json", - ], - sharedConfig - ) - ).toMatchInlineSnapshot(` - { - "css": {}, - "cssVars": {}, - "dependencies": [], - "devDependencies": [], - "docs": "", - "files": [ - { - "content": "export const Button = () => ", - "path": "ui/button.tsx", - "type": "registry:ui", - }, - { - "content": "export { Button } from './button'", - "path": "components/button/index.tsx", - "type": "registry:ui", - }, - { - "content": ".button-a { color: red; }", - "path": "ui/button.styles.css", - "type": "registry:ui", - }, - ], - "tailwind": {}, - } - `) - }) - - test("should handle explicit target overrides", async () => { - expect( - await registryResolveItemsTree( - [ - "http://localhost:4449/r/button-variant-a.json", - "http://localhost:4449/r/component-with-explicit-target.json", - ], - sharedConfig - ) - ).toMatchInlineSnapshot(` - { - "css": {}, - "cssVars": {}, - "dependencies": [], - "devDependencies": [], - "docs": "", - "files": [ - { - "content": "export const Button = () => ", - "path": "ui/button.tsx", - "type": "registry:ui", - }, - { - "content": ".button-a { color: red; }", - "path": "ui/button.styles.css", - "type": "registry:ui", - }, - { - "content": "export const Component = () =>
Custom
", - "path": "custom/component.tsx", - "target": "components/ui/button.tsx", - "type": "registry:ui", - }, - ], - "tailwind": {}, - } - `) - }) - - test("should preserve files with different types even if paths are similar", async () => { - expect( - await registryResolveItemsTree( - ["http://localhost:4449/r/lib-and-ui-conflict.json"], - sharedConfig - ) - ).toMatchInlineSnapshot(` - { - "css": {}, - "cssVars": {}, - "dependencies": [], - "devDependencies": [], - "docs": "", - "files": [ - { - "content": "export const utils = () => {}", - "path": "utils.ts", - "type": "registry:lib", - }, - { - "content": "export const uiUtils = () => {}", - "path": "ui/utils.ts", - "type": "registry:ui", - }, - ], - "tailwind": {}, - } - `) - }) - - test("should handle complex nested paths and deduplicate correctly", async () => { - // Create a custom registry with nested paths. - const nestedRegistry = await createRegistryServer( - [ - { - name: "nested-a", - type: "registry:ui", - files: [ - { - path: "ui/forms/input.tsx", - content: "export const Input = () => ", - type: "registry:ui", - }, - { - path: "ui/forms/button.tsx", - content: - "export const FormButton = () => ", - type: "registry:ui", - }, - ], - }, - { - name: "nested-b", - type: "registry:ui", - files: [ - { - path: "ui/forms/button.tsx", - content: - "export const FormButton = () => ", - type: "registry:ui", - }, - { - path: "ui/forms/select.tsx", - content: "export const Select = () => ", - "path": "ui/forms/input.tsx", - "type": "registry:ui", - }, - { - "content": "export const FormButton = () => ", - "path": "ui/forms/button.tsx", - "type": "registry:ui", - }, - { - "content": "export const Select = () =>