add getRegistriesIndex (#8216)

* feat: add getRegistriesIndex

* chore: changeset

* fix: formatting
This commit is contained in:
shadcn
2025-09-15 14:55:05 +04:00
committed by GitHub
parent 590b9be610
commit fc6d909ba2
6 changed files with 158 additions and 12 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add getRegistriesIndex

View File

@@ -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()
})
})
})

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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"

View File

@@ -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,