diff --git a/.changeset/poor-toys-visit.md b/.changeset/poor-toys-visit.md new file mode 100644 index 0000000000..4e77bfc7ab --- /dev/null +++ b/.changeset/poor-toys-visit.md @@ -0,0 +1,5 @@ +--- +"shadcn": major +--- + +add getRegistry diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index 799303e53b..eb169b3cfb 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -23,6 +23,7 @@ import { it, vi, } from "vitest" +import { z } from "zod" import { getRegistry, getRegistryItems } from "./api" @@ -718,4 +719,287 @@ describe("getRegistry", () => { RegistryFetchError ) }) + + it("should throw RegistryNotConfiguredError when registry is not in config", async () => { + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + } as any + + await expect(getRegistry("@nonexistent", mockConfig)).rejects.toThrow( + RegistryNotConfiguredError + ) + }) + + it("should handle registry with no items gracefully", async () => { + const registryData = { + name: "@empty/registry", + homepage: "https://empty.com", + items: [], + } + + server.use( + http.get("https://empty.com/registry.json", () => { + return HttpResponse.json(registryData) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@empty": { + url: "https://empty.com/{name}.json", + }, + }, + } as any + + const result = await getRegistry("@empty", mockConfig) + expect(result).toMatchObject(registryData) + expect(result.items).toHaveLength(0) + }) + + it("should handle 404 error from registry endpoint", async () => { + server.use( + http.get("https://notfound.com/registry.json", () => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@notfound": { + url: "https://notfound.com/{name}.json", + }, + }, + } as any + + await expect(getRegistry("@notfound", mockConfig)).rejects.toThrow( + RegistryNotFoundError + ) + }) + + it("should handle 401 error from registry endpoint", async () => { + server.use( + http.get("https://unauthorized.com/registry.json", () => { + return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@unauthorized": { + url: "https://unauthorized.com/{name}.json", + }, + }, + } as any + + await expect(getRegistry("@unauthorized", mockConfig)).rejects.toThrow( + RegistryUnauthorizedError + ) + }) + + it("should handle 403 error from registry endpoint", async () => { + server.use( + http.get("https://forbidden.com/registry.json", () => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@forbidden": { + url: "https://forbidden.com/{name}.json", + }, + }, + } as any + + await expect(getRegistry("@forbidden", mockConfig)).rejects.toThrow( + RegistryForbiddenError + ) + }) + + it("should set headers in context when provided", async () => { + const registryData = { + name: "@headers-test/registry", + homepage: "https://headers.com", + items: [], + } + + let receivedHeaders: Record = {} + server.use( + http.get("https://headers.com/registry.json", ({ request }) => { + request.headers.forEach((value, key) => { + receivedHeaders[key] = value + }) + return HttpResponse.json(registryData) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@headers-test": { + url: "https://headers.com/{name}.json", + headers: { + "X-Custom-Header": "test-value", + Authorization: "Bearer test-token", + }, + }, + }, + } as any + + await getRegistry("@headers-test", mockConfig) + + expect(receivedHeaders["x-custom-header"]).toBe("test-value") + expect(receivedHeaders.authorization).toBe("Bearer test-token") + }) + + it("should not set headers in context when none provided", async () => { + const registryData = { + name: "@no-headers/registry", + homepage: "https://noheaders.com", + items: [], + } + + server.use( + http.get("https://noheaders.com/registry.json", () => { + return HttpResponse.json(registryData) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@no-headers": { + url: "https://noheaders.com/{name}.json", + }, + }, + } as any + + const result = await getRegistry("@no-headers", mockConfig) + expect(result).toMatchObject(registryData) + }) + + it("should handle registry items with slashes", async () => { + const registryData = { + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + } + + server.use( + http.get("https://acme.com/sub/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 + + const result = await getRegistry("@acme/sub", mockConfig) + expect(result).toMatchObject(registryData) + }) + + it("should use configWithDefaults to fill missing config values", async () => { + const registryData = { + name: "@defaults/registry", + homepage: "https://defaults.com", + items: [], + } + + server.use( + http.get("https://defaults.com/registry.json", () => { + return HttpResponse.json(registryData) + }) + ) + + const minimalConfig = { + registries: { + "@defaults": { + url: "https://defaults.com/{name}.json", + }, + }, + } as any + + const result = await getRegistry("@defaults", minimalConfig) + expect(result).toMatchObject(registryData) + }) + + it("should handle malformed JSON response", async () => { + server.use( + http.get("https://malformed.com/registry.json", () => { + return new Response("{ malformed json }", { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@malformed": { + url: "https://malformed.com/{name}.json", + }, + }, + } as any + + await expect(getRegistry("@malformed", mockConfig)).rejects.toThrow() + }) + + it("should throw RegistryParseError with proper context", async () => { + const invalidData = { + homepage: "https://invalid.com", + items: "not-an-array", + } + + server.use( + http.get("https://parsetest.com/registry.json", () => { + return HttpResponse.json(invalidData) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@parsetest": { + url: "https://parsetest.com/{name}.json", + }, + }, + } as any + + try { + await getRegistry("@parsetest/registry", mockConfig) + expect.fail("Should have thrown RegistryParseError") + } catch (error) { + expect(error).toBeInstanceOf(RegistryParseError) + if (error instanceof RegistryParseError) { + expect(error.message).toContain("Failed to parse registry") + expect(error.message).toContain("@parsetest/registry") + expect(error.context?.item).toBe("@parsetest/registry") + expect(error.parseError).toBeDefined() + if (error.parseError instanceof z.ZodError) { + expect(error.parseError.errors.length).toBeGreaterThan(0) + } + } + } + }) }) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index 9c661f2a64..bb213792ac 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -28,12 +28,18 @@ import { handleError } from "@/src/utils/handle-error" import { logger } from "@/src/utils/logger" import { z } from "zod" -export async function getRegistry(name: `@${string}`, config?: Config) { +export async function getRegistry( + name: `@${string}`, + config?: Partial +) { if (!name.endsWith("/registry")) { name = `${name}/registry` } - const urlAndHeaders = buildUrlAndHeadersForRegistryItem(name, config) + const urlAndHeaders = buildUrlAndHeadersForRegistryItem( + name, + configWithDefaults(config) + ) if (!urlAndHeaders?.url) { throw new RegistryNotFoundError(name) diff --git a/packages/shadcn/src/registry/index.ts b/packages/shadcn/src/registry/index.ts index 926e4e715a..ae1970fe04 100644 --- a/packages/shadcn/src/registry/index.ts +++ b/packages/shadcn/src/registry/index.ts @@ -1,4 +1,4 @@ -export { getRegistryItems, resolveRegistryItems } from "./api" +export { getRegistryItems, resolveRegistryItems, getRegistry } from "./api" export { RegistryError,