From fc6d909ba23ac1ba09cf32087f0524aca398b5aa Mon Sep 17 00:00:00 2001 From: shadcn Date: Mon, 15 Sep 2025 14:55:05 +0400 Subject: [PATCH] add getRegistriesIndex (#8216) * feat: add getRegistriesIndex * chore: changeset * fix: formatting --- .changeset/tangy-spiders-hide.md | 5 ++ packages/shadcn/src/registry/api.test.ts | 86 +++++++++++++++++++++++- packages/shadcn/src/registry/api.ts | 26 ++++--- packages/shadcn/src/registry/errors.ts | 38 +++++++++++ packages/shadcn/src/registry/index.ts | 8 ++- packages/shadcn/src/utils/registries.ts | 7 +- 6 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 .changeset/tangy-spiders-hide.md diff --git a/.changeset/tangy-spiders-hide.md b/.changeset/tangy-spiders-hide.md new file mode 100644 index 0000000000..ec30616050 --- /dev/null +++ b/.changeset/tangy-spiders-hide.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add getRegistriesIndex diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index a3f869df56..446ae9e55a 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -27,7 +27,13 @@ import { } from "vitest" import { z } from "zod" -import { getRegistriesConfig, getRegistry, getRegistryItems } from "./api" +import { + getRegistriesConfig, + getRegistriesIndex, + getRegistry, + getRegistryItems, +} from "./api" +import { RegistriesIndexParseError } from "./errors" vi.mock("@/src/utils/handle-error", () => ({ handleError: vi.fn(), @@ -96,6 +102,13 @@ const server = setupServer( }, ], }) + }), + http.get(`${REGISTRY_URL}/registries.json`, () => { + return HttpResponse.json({ + "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json", + "@example": "https://example.com/registry/styles/{style}/{name}.json", + "@test": "https://test.com/registry/{name}.json", + }) }) ) @@ -1650,4 +1663,75 @@ describe("getRegistriesConfig", () => { } }) }) + + describe("getRegistriesIndex", () => { + it("should fetch and parse the registries index successfully", async () => { + const result = await getRegistriesIndex() + + expect(result).toEqual({ + "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json", + "@example": "https://example.com/registry/styles/{style}/{name}.json", + "@test": "https://test.com/registry/{name}.json", + }) + }) + + it("should respect cache options", async () => { + // Test with cache disabled + const result1 = await getRegistriesIndex({ useCache: false }) + expect(result1).toBeDefined() + + // Test with cache enabled + const result2 = await getRegistriesIndex({ useCache: true }) + expect(result2).toBeDefined() + + // Results should be the same + expect(result1).toEqual(result2) + }) + + it("should use default cache behavior when no options provided", async () => { + const result = await getRegistriesIndex() + expect(result).toBeDefined() + expect(typeof result).toBe("object") + }) + + it("should handle network errors properly", async () => { + server.use( + http.get(`${REGISTRY_URL}/registries.json`, () => { + return new HttpResponse(null, { status: 500 }) + }) + ) + + await expect(getRegistriesIndex({ useCache: false })).rejects.toThrow() + + try { + await getRegistriesIndex({ useCache: false }) + } catch (error) { + expect(error).not.toBeInstanceOf(RegistriesIndexParseError) + } + }) + + it("should handle invalid JSON response", async () => { + server.use( + http.get(`${REGISTRY_URL}/registries.json`, () => { + return HttpResponse.json({ + "invalid-namespace": "some-url", + }) + }) + ) + + await expect(getRegistriesIndex({ useCache: false })).rejects.toThrow( + RegistriesIndexParseError + ) + }) + + it("should handle network timeout", async () => { + server.use( + http.get(`${REGISTRY_URL}/registries.json`, () => { + return HttpResponse.error() + }) + ) + + await expect(getRegistriesIndex({ useCache: false })).rejects.toThrow() + }) + }) }) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index 8ee50c3c28..f66d37c845 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -12,6 +12,7 @@ import { } from "@/src/registry/context" import { ConfigParseError, + RegistriesIndexParseError, RegistryInvalidNamespaceError, RegistryNotFoundError, RegistryParseError, @@ -277,15 +278,24 @@ export async function getItemTargetPath( ) } -export async function fetchRegistries() { +export async function getRegistriesIndex(options?: { useCache?: boolean }) { + options = { + useCache: true, + ...options, + } + + const url = `${REGISTRY_URL}/registries.json` + const [data] = await fetchRegistry([url], { + useCache: options.useCache, + }) + try { - // TODO: Do we want this inside /r? - const url = `${REGISTRY_URL}/registries.json` - const [data] = await fetchRegistry([url], { - useCache: process.env.NODE_ENV !== "development", - }) return registriesIndexSchema.parse(data) - } catch { - return null + } catch (error) { + if (error instanceof z.ZodError) { + throw new RegistriesIndexParseError(error) + } + + throw error } } diff --git a/packages/shadcn/src/registry/errors.ts b/packages/shadcn/src/registry/errors.ts index c5e45a8fe5..67e21c9a32 100644 --- a/packages/shadcn/src/registry/errors.ts +++ b/packages/shadcn/src/registry/errors.ts @@ -285,3 +285,41 @@ export class ConfigParseError extends RegistryError { this.name = "ConfigParseError" } } + +export class RegistriesIndexParseError extends RegistryError { + public readonly parseError: unknown + + constructor(parseError: unknown) { + let message = "Failed to parse registries index" + + if (parseError instanceof z.ZodError) { + const invalidNamespaces = parseError.errors + .filter((e) => e.path.length > 0) + .map((e) => `"${e.path[0]}"`) + .filter((v, i, arr) => arr.indexOf(v) === i) // remove duplicates + + if (invalidNamespaces.length > 0) { + message = `Failed to parse registries index. Invalid registry namespace(s): ${invalidNamespaces.join( + ", " + )}\n${parseError.errors + .map((e) => ` - ${e.path.join(".")}: ${e.message}`) + .join("\n")}` + } else { + message = `Failed to parse registries index:\n${parseError.errors + .map((e) => ` - ${e.path.join(".")}: ${e.message}`) + .join("\n")}` + } + } + + super(message, { + code: RegistryErrorCode.PARSE_ERROR, + cause: parseError, + context: { parseError }, + suggestion: + "The registries index may be corrupted or have invalid registry namespace format. Registry names must start with @ (e.g., @shadcn, @example).", + }) + + this.parseError = parseError + this.name = "RegistriesIndexParseError" + } +} diff --git a/packages/shadcn/src/registry/index.ts b/packages/shadcn/src/registry/index.ts index e034e13a38..8ea1eb48b5 100644 --- a/packages/shadcn/src/registry/index.ts +++ b/packages/shadcn/src/registry/index.ts @@ -1,4 +1,9 @@ -export { getRegistryItems, resolveRegistryItems, getRegistry } from "./api" +export { + getRegistryItems, + resolveRegistryItems, + getRegistry, + getRegistriesIndex, +} from "./api" export { searchRegistries } from "./search" @@ -11,6 +16,7 @@ export { RegistryNotConfiguredError, RegistryLocalFileError, RegistryParseError, + RegistriesIndexParseError, RegistryMissingEnvironmentVariablesError, RegistryInvalidNamespaceError, } from "./errors" diff --git a/packages/shadcn/src/utils/registries.ts b/packages/shadcn/src/utils/registries.ts index e642be1097..f0471eaa9e 100644 --- a/packages/shadcn/src/utils/registries.ts +++ b/packages/shadcn/src/utils/registries.ts @@ -1,5 +1,5 @@ import path from "path" -import { fetchRegistries } from "@/src/registry/api" +import { getRegistriesIndex } from "@/src/registry/api" import { BUILTIN_REGISTRIES } from "@/src/registry/constants" import { resolveRegistryNamespaces } from "@/src/registry/namespaces" import { rawConfigSchema } from "@/src/registry/schema" @@ -39,7 +39,10 @@ export async function ensureRegistriesInConfig( // We'll fail silently if we can't fetch the registry index. // The error handling by caller will guide user to add the missing registries. - const registryIndex = await fetchRegistries() + const registryIndex = await getRegistriesIndex({ + useCache: process.env.NODE_ENV !== "development", + }) + if (!registryIndex) { return { config,