diff --git a/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx b/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx new file mode 100644 index 00000000000..283d705f2e3 --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx @@ -0,0 +1,44 @@ +--- +title: July 2026 - Dynamic Search +description: Registries can now handle search server-side. +date: 2026-07-31 +--- + +**Registries can now handle search server-side.** + +When you run `shadcn search`, the CLI forwards the search parameters to your +registry as query params: + +```txt +GET /r/registry.json?q=button&limit=50&offset=0 +``` + +Return the matching items with a `pagination` object and the CLI uses your +results as-is. This makes search fast for large registries: no more downloading +the full catalog to search it. + +```json title="registry.json?q=button&limit=1" +{ + "name": "acme", + "homepage": "https://acme.com", + "items": [ + { + "name": "button", + "type": "registry:ui", + "description": "A button component." + } + ], + "pagination": { + "total": 12, + "offset": 0, + "limit": 1, + "hasMore": true + } +} +``` + +Dynamic search is opt-in. Static registries ignore the query params and keep +working without any changes. + +See the [Dynamic Search](/docs/registry/dynamic-search) docs for the full +guide. diff --git a/apps/v4/content/docs/registry/dynamic-search.mdx b/apps/v4/content/docs/registry/dynamic-search.mdx new file mode 100644 index 00000000000..8507395e919 --- /dev/null +++ b/apps/v4/content/docs/registry/dynamic-search.mdx @@ -0,0 +1,199 @@ +--- +title: Dynamic Search +description: Implement server-side search for large registries. +--- + +By default, `shadcn search` fetches your entire `registry.json` and filters +items locally. This works well for most registries and requires nothing more +than a static file. + +For large registries with thousands of items, you can implement search on your +registry server instead. The CLI forwards the search parameters to your +registry and your server returns only the matching items. + +Dynamic search is opt-in and fully backwards compatible. Static registries +keep working without any changes. + +## How It Works + +When you run `shadcn search`, the CLI appends the search parameters to the +catalog request: + +```bash +npx shadcn@latest search @acme --query button --limit 50 +``` + +```txt +GET https://acme.com/r/registry.json?q=button&limit=50&offset=0 +``` + +What happens next depends on your registry: + +- **Static registries** ignore the query parameters and return the full + `registry.json`. The CLI filters the items locally. This is the default + behavior and requires no changes. +- **Dynamic registries** filter the items server-side and return the matching + items along with a `pagination` object. When the CLI sees `pagination` in + the response, it trusts the results as pre-filtered and skips local + filtering. + +The presence of `pagination` in the response is what tells the CLI your +registry handles search server-side. There is no configuration or capability +negotiation required. + +## Query Parameters + +The CLI sends the following query parameters with every search request: + +| Parameter | Description | +| --------- | -------------------------------------------------------------- | +| `q` | The search query string. | +| `type` | Comma-separated item types, e.g. `registry:ui,registry:block`. | +| `limit` | Maximum number of items to return. | +| `offset` | Number of items to skip. | + +All parameters are optional. A request without `q` or `type` should return +all items, paginated. + +## Response Format + +Return your regular `registry.json` shape with an additional `pagination` +object: + +```json title="registry.json?q=button&limit=2" +{ + "name": "acme", + "homepage": "https://acme.com", + "items": [ + { + "name": "button", + "type": "registry:ui", + "description": "A button component." + }, + { + "name": "icon-button", + "type": "registry:ui", + "description": "A button component with an icon." + } + ], + "pagination": { + "total": 12, + "offset": 0, + "limit": 2, + "hasMore": true + } +} +``` + +Search results only need `name`, `type` and `description` for each item. You +do not need to include `files`, `dependencies` or other item properties. The +CLI fetches the full item definition when the user runs `shadcn add`. + +### pagination + +| Property | Type | Description | +| --------- | --------- | -------------------------------------------------- | +| `total` | `number` | Total number of items matching the query. | +| `offset` | `number` | Number of items skipped. | +| `limit` | `number` | Maximum number of items in this response. | +| `hasMore` | `boolean` | Whether more items are available beyond this page. | + +## Server Implementation + +Here's an example using a Next.js route handler: + +```typescript title="app/r/registry.json/route.ts" +import { NextRequest, NextResponse } from "next/server" + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + + const query = searchParams.get("q") + const types = searchParams.get("type")?.split(",") + const limit = Number(searchParams.get("limit") ?? 100) + const offset = Number(searchParams.get("offset") ?? 0) + + // Filter items using your database or search index. + const { items, total } = await searchItems({ query, types, limit, offset }) + + return NextResponse.json({ + name: "acme", + homepage: "https://acme.com", + items, + pagination: { + total, + offset, + limit, + hasMore: offset + limit < total, + }, + }) +} +``` + +You can back `searchItems` with anything: a database query, a full-text +search index or an external search service. + +## Multiple Registries + +When searching a single registry, the CLI forwards all parameters and uses +your `pagination` response as-is. + +When searching multiple registries at once, e.g. `shadcn search @acme @lib`, +a global `offset` cannot be split across registries. The CLI forwards `q` and +`type` to each registry along with a `limit` large enough to fill the +requested page (`offset + limit`), then merges and paginates the combined +results locally. Server-side filtering still applies, so each registry only +returns matching items. + +If your registry caps the number of items per response below the requested +`limit`, the CLI treats it as exhausted for deeper pages. Honor the requested +`limit` where possible so all your matches stay reachable through paging. + +## Authentication + +Dynamic search works with all [authentication](/docs/registry/authentication) +patterns. The CLI sends the configured headers and params with the search +request, so you can scope search results to the authenticated user: + +```typescript title="app/r/registry.json/route.ts" +export async function GET(request: NextRequest) { + const token = request.headers.get("authorization")?.replace("Bearer ", "") + const team = await getTeamFromToken(token) + + // Only search items this team can access. + const { items, total } = await searchItems({ + query: request.nextUrl.searchParams.get("q"), + team, + }) + + // ... +} +``` + +## Backwards Compatibility + +- **Static registries** require no changes. Query parameters on a static file + are ignored by the file server and the CLI falls back to local filtering. +- **Older CLI versions** fetch the catalog without query parameters and + ignore the `pagination` field. Your registry should return a sensible + default response for requests without parameters, e.g. the first page of + items. +- **Ranking** is up to your server. When your registry returns pre-filtered + results, the CLI preserves your order instead of re-ranking locally. + +## Testing + +Test your dynamic registry with `curl`: + +```bash +curl "https://acme.com/r/registry.json?q=button&limit=10" +``` + +Then verify with the CLI: + +```bash +npx shadcn@latest search @acme --query button +``` + +To confirm server-side search is active, check that the response includes the +`pagination` object and only the matching items. diff --git a/apps/v4/content/docs/registry/meta.json b/apps/v4/content/docs/registry/meta.json index 61a2cac9653..965ff964244 100644 --- a/apps/v4/content/docs/registry/meta.json +++ b/apps/v4/content/docs/registry/meta.json @@ -8,6 +8,7 @@ "examples", "namespace", "authentication", + "dynamic-search", "mcp", "open-in-v0", "api-reference", diff --git a/apps/v4/lib/docs.ts b/apps/v4/lib/docs.ts index cf9b44393df..64d617b3dbb 100644 --- a/apps/v4/lib/docs.ts +++ b/apps/v4/lib/docs.ts @@ -16,6 +16,7 @@ export const PAGES_NEW = [ "/docs/helpers/ai-sdk", "/docs/helpers/tanstack-ai", "/docs/react/message-scroller", + "/docs/registry/dynamic-search", ] export const PAGES_UPDATED = [] diff --git a/apps/v4/package.json b/apps/v4/package.json index e6c172b9ba2..48ed248e9e7 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -90,7 +90,7 @@ "rehype-pretty-code": "^0.14.1", "rimraf": "^6.0.1", "server-only": "^0.0.1", - "shadcn": "4.16.0", + "shadcn": "4.16.1", "shiki": "^3.23.0", "sonner": "^2.0.0", "streamdown": "^2.5.0", diff --git a/apps/v4/public/schema/registry.json b/apps/v4/public/schema/registry.json index 3201ec6c2f0..7243e0786ec 100644 --- a/apps/v4/public/schema/registry.json +++ b/apps/v4/public/schema/registry.json @@ -27,6 +27,29 @@ "items": { "$ref": "https://ui.shadcn.com/schema/registry-item.json" } + }, + "pagination": { + "type": "object", + "description": "Pagination metadata returned by registries that implement dynamic search. Its presence on a catalog response signals that the items are already filtered and paginated server-side.", + "properties": { + "total": { + "type": "number", + "description": "Total number of items matching the query." + }, + "offset": { + "type": "number", + "description": "Number of items skipped." + }, + "limit": { + "type": "number", + "description": "Maximum number of items in this response." + }, + "hasMore": { + "type": "boolean", + "description": "Whether more items are available beyond this page." + } + }, + "required": ["total", "offset", "limit", "hasMore"] } }, "anyOf": [{ "required": ["items"] }, { "required": ["include"] }] diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json index ed7ad60036f..8f59dbcd21f 100644 --- a/apps/v4/registry/directory.json +++ b/apps/v4/registry/directory.json @@ -1841,5 +1841,12 @@ "url": "https://ai2.design/r/{name}.json", "description": "Agent-native design system for the shadcn CLI: 51 base components with a full variant, tone and size matrix on an OKLCH token layer, plus 307 styled variations. MIT.", "logo": "" + }, + { + "name": "@whiskeyjack", + "homepage": "https://whiskeyjack.net", + "url": "https://whiskeyjack.net/r/{name}.json", + "description": "A Tauri-first design system: thumb-first components with Metro-style pivot navigation, frosted bottom nav, and tap-again confirmations, on a CSS-variable token pipeline. Hardened across nine shipping apps before extraction.", + "logo": "" } ] diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index ceebf8c9c88..090cc314b72 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,5 +1,13 @@ # shadcn +## 4.16.1 + +### Patch Changes + +- [#11322](https://github.com/shadcn-ui/ui/pull/11322) [`bfa1b5e9a69a155b2f590523d50fda810bde1a9a`](https://github.com/shadcn-ui/ui/commit/bfa1b5e9a69a155b2f590523d50fda810bde1a9a) Thanks [@AndrewBarba](https://github.com/AndrewBarba)! - fix `shadcn build` failing with ENOENT when registry item names contain path segments (e.g. `extension/foo`) by creating nested output directories before writing + +- [#11352](https://github.com/shadcn-ui/ui/pull/11352) [`5ca53ca7c7dea390e0e78091ff7c54adc48c773a`](https://github.com/shadcn-ui/ui/commit/5ca53ca7c7dea390e0e78091ff7c54adc48c773a) Thanks [@shadcn](https://github.com/shadcn)! - forward search params to registries for server-side dynamic search + ## 4.16.0 ### Minor Changes diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index bbf19f1d49b..48d3792a8f7 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -1,6 +1,6 @@ { "name": "shadcn", - "version": "4.16.0", + "version": "4.16.1", "description": "Add components to your apps.", "publishConfig": { "access": "public" diff --git a/packages/shadcn/src/commands/build.test.ts b/packages/shadcn/src/commands/build.test.ts index 5b63b61da2e..20013f284c0 100644 --- a/packages/shadcn/src/commands/build.test.ts +++ b/packages/shadcn/src/commands/build.test.ts @@ -83,6 +83,48 @@ describe("build command", () => { ], }) }) + + it("creates nested output directories for item names with path segments", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "extension/foo", + type: "registry:item", + files: [ + { + path: "registry/extensions/foo.tsx", + type: "registry:file", + target: "extensions/foo.tsx", + }, + ], + }, + ], + }), + "registry/extensions/foo.tsx": "export function Foo() {}", + }) + + await build.parseAsync( + ["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"], + { from: "node" } + ) + + const item = JSON.parse( + await fs.readFile(path.join(cwd, "public/r/extension/foo.json"), "utf-8") + ) + + expect(item).toMatchObject({ + name: "extension/foo", + files: [ + { + path: "registry/extensions/foo.tsx", + content: "export function Foo() {}", + }, + ], + }) + }) }) async function createFixture(files: Record) { diff --git a/packages/shadcn/src/commands/build.ts b/packages/shadcn/src/commands/build.ts index f6426c92693..5b9ba00cf33 100644 --- a/packages/shadcn/src/commands/build.ts +++ b/packages/shadcn/src/commands/build.ts @@ -69,11 +69,15 @@ export const build = new Command() ) // Write the registry item to the output directory. + // Item names can contain path segments (e.g. "extension/foo"), so + // ensure the nested output directory exists before writing. + const outputPath = path.resolve( + resolvePaths.outputDir, + `${registryItemForBuild.name}.json` + ) + await fs.mkdir(path.dirname(outputPath), { recursive: true }) await fs.writeFile( - path.resolve( - resolvePaths.outputDir, - `${registryItemForBuild.name}.json` - ), + outputPath, JSON.stringify(registryItemForBuild, null, 2) ) } diff --git a/packages/shadcn/src/commands/registry/build.ts b/packages/shadcn/src/commands/registry/build.ts index 942640684d8..aa0887cdb56 100644 --- a/packages/shadcn/src/commands/registry/build.ts +++ b/packages/shadcn/src/commands/registry/build.ts @@ -149,10 +149,14 @@ async function buildRegistry(opts: z.infer) { } // Write the registry item to the output directory. - await fs.writeFile( - path.resolve(resolvePaths.outputDir, `${result.data.name}.json`), - JSON.stringify(result.data, null, 2) + // Item names can contain path segments (e.g. "extension/foo"), so + // ensure the nested output directory exists before writing. + const outputPath = path.resolve( + resolvePaths.outputDir, + `${result.data.name}.json` ) + await fs.mkdir(path.dirname(outputPath), { recursive: true }) + await fs.writeFile(outputPath, JSON.stringify(result.data, null, 2)) } // Copy registry.json to the output directory. diff --git a/packages/shadcn/src/commands/search.ts b/packages/shadcn/src/commands/search.ts index a9a5e6f58a6..3c540fdaeb9 100644 --- a/packages/shadcn/src/commands/search.ts +++ b/packages/shadcn/src/commands/search.ts @@ -1,5 +1,6 @@ import path from "path" import { configWithDefaults } from "@/src/registry/config" +import { BUILTIN_REGISTRIES } from "@/src/registry/constants" import { clearRegistryContext } from "@/src/registry/context" import { findUnknownSearchTypes, @@ -143,13 +144,20 @@ export const search = new Command() process.exit(1) } - // Only namespace registries passed explicitly need to be discovered and - // added to the config. Registries already configured in components.json - // are resolved directly from the config below. + // Only namespace registries that are not already configured need to be + // discovered and added to the config. Registries in components.json (or + // builtins) are resolved directly from the config below. Skipping + // configured registries also avoids the discovery step downloading the + // full catalog before the search request. const { config: updatedConfig, newRegistries } = await ensureRegistriesInConfig( registries - .filter((registry) => registry.startsWith("@")) + .filter( + (registry) => + registry.startsWith("@") && + !config.registries?.[registry] && + !(registry in BUILTIN_REGISTRIES) + ) .map((registry) => `${registry}/registry`), config, { diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index 6d40732525d..345d198182f 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -791,6 +791,185 @@ describe("getRegistry", () => { expect(receivedHeaders.authorization).toBe("Bearer test-token") }) + it("should forward search params as query params", async () => { + let receivedUrl = "" + server.use( + http.get("https://acme.com/registry.json", ({ request }) => { + receivedUrl = request.url + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + await getRegistry("@acme", { + config: mockConfig, + searchParams: { + query: "button", + types: ["registry:ui", "registry:block"], + limit: 50, + offset: 10, + }, + }) + + const url = new URL(receivedUrl) + expect(url.searchParams.get("q")).toBe("button") + expect(url.searchParams.get("type")).toBe("registry:ui,registry:block") + expect(url.searchParams.get("limit")).toBe("50") + expect(url.searchParams.get("offset")).toBe("10") + }) + + it("should not append query params without search params", async () => { + let receivedUrl = "" + server.use( + http.get("https://acme.com/registry.json", ({ request }) => { + receivedUrl = request.url + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + await getRegistry("@acme", { config: mockConfig }) + + expect(receivedUrl).toBe("https://acme.com/registry.json") + }) + + it("should forward search params on direct registry URLs", async () => { + let receivedUrl = "" + server.use( + http.get("https://acme.com/r/registry.json", ({ request }) => { + receivedUrl = request.url + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + }) + }) + ) + + await getRegistry("https://acme.com/r/registry.json", { + searchParams: { + query: "button", + limit: 10, + }, + }) + + const url = new URL(receivedUrl) + expect(url.searchParams.get("q")).toBe("button") + expect(url.searchParams.get("limit")).toBe("10") + expect(url.searchParams.get("offset")).toBeNull() + expect(url.searchParams.get("type")).toBeNull() + }) + + it("should preserve configured auth params when appending search params", async () => { + let receivedUrl = "" + let receivedAuthHeaders: Record = {} + server.use( + http.get("https://private.com/registry.json", ({ request }) => { + receivedUrl = request.url + request.headers.forEach((value, key) => { + receivedAuthHeaders[key] = value + }) + return HttpResponse.json({ + name: "@private/registry", + homepage: "https://private.com", + items: [], + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@private": { + url: "https://private.com/{name}.json", + params: { + token: "test-token", + }, + headers: { + Authorization: "Bearer test-token", + }, + }, + }, + } as any + + await getRegistry("@private", { + config: mockConfig, + searchParams: { query: "button" }, + }) + + const url = new URL(receivedUrl) + expect(url.searchParams.get("token")).toBe("test-token") + expect(url.searchParams.get("q")).toBe("button") + expect(receivedAuthHeaders.authorization).toBe("Bearer test-token") + }) + + it("should return pagination from dynamic registries", async () => { + server.use( + http.get("https://acme.com/registry.json", () => { + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [{ name: "button", type: "registry:ui" }], + pagination: { + total: 1000, + offset: 0, + limit: 1, + hasMore: true, + }, + }) + }) + ) + + 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", { + config: mockConfig, + searchParams: { query: "button" }, + }) + + expect(result.pagination).toEqual({ + total: 1000, + offset: 0, + limit: 1, + hasMore: true, + }) + }) + it("should throw RegistryNotConfiguredError when registry is not configured", async () => { const mockConfig = { style: "new-york", diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index ce6b3874e66..dd9ecc869df 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -56,21 +56,67 @@ type RegistryApiOptions = { useCache?: boolean } -export async function getRegistry(name: string, options?: RegistryApiOptions) { +// Search parameters forwarded to the registry as query params so dynamic +// registries can filter server-side. Static registries ignore them and return +// the full catalog. See https://ui.shadcn.com/docs/registry/dynamic-search. +type RegistrySearchParams = { + query?: string + types?: string[] + limit?: number + offset?: number +} + +type GetRegistryOptions = RegistryApiOptions & { + searchParams?: RegistrySearchParams +} + +function appendSearchParamsToUrl( + url: string, + searchParams?: RegistrySearchParams +) { + if (!searchParams) { + return url + } + + const parsedUrl = new URL(url) + + if (searchParams.query) { + parsedUrl.searchParams.set("q", searchParams.query) + } + + if (searchParams.types?.length) { + parsedUrl.searchParams.set("type", searchParams.types.join(",")) + } + + if (searchParams.limit !== undefined) { + parsedUrl.searchParams.set("limit", String(searchParams.limit)) + } + + if (searchParams.offset !== undefined) { + parsedUrl.searchParams.set("offset", String(searchParams.offset)) + } + + return parsedUrl.toString() +} + +export async function getRegistry(name: string, options?: GetRegistryOptions) { return withRegistryContext(() => getRegistryWithContext(name, options)) } async function getRegistryWithContext( name: string, - options?: RegistryApiOptions + options?: GetRegistryOptions ) { - const { config, useCache } = options || {} + const { config, useCache, searchParams } = options || {} if (isUrl(name)) { - const [result] = await fetchRegistry([name], { useCache }) + const url = appendSearchParamsToUrl(name, searchParams) + const [result] = await fetchRegistry([url], { useCache }) return parseRegistryCatalog(name, result) } + // GitHub registries are raw files. There is no server to run a search, so + // search params are not forwarded and filtering happens locally. const githubSource = resolveGitHubRegistrySource(name) if (githubSource) { return fetchGitHubRegistryCatalog(githubSource, { useCache }) @@ -94,13 +140,17 @@ async function getRegistryWithContext( throw new RegistryNotFoundError(registryName) } + // Append search params before registering headers so the header lookup key + // matches the URL we actually fetch. + const url = appendSearchParamsToUrl(urlAndHeaders.url, searchParams) + if (urlAndHeaders.headers && Object.keys(urlAndHeaders.headers).length > 0) { setRegistryHeaders({ - [urlAndHeaders.url]: urlAndHeaders.headers, + [url]: urlAndHeaders.headers, }) } - const [result] = await fetchRegistry([urlAndHeaders.url], { useCache }) + const [result] = await fetchRegistry([url], { useCache }) return parseRegistryCatalog(registryName, result) } diff --git a/packages/shadcn/src/registry/schema.test.ts b/packages/shadcn/src/registry/schema.test.ts index 808b5d8ae9f..8115dc9676c 100644 --- a/packages/shadcn/src/registry/schema.test.ts +++ b/packages/shadcn/src/registry/schema.test.ts @@ -80,4 +80,54 @@ describe("registrySchema", () => { expect(result.success).toBe(false) }) + + it("should accept registries with pagination", () => { + const result = registrySchema.safeParse({ + name: "example", + homepage: "https://example.com", + items: [{ name: "button", type: "registry:ui" }], + pagination: { + total: 100, + offset: 0, + limit: 1, + hasMore: true, + }, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination).toEqual({ + total: 100, + offset: 0, + limit: 1, + hasMore: true, + }) + } + }) + + it("should accept registries without pagination", () => { + const result = registrySchema.safeParse({ + name: "example", + homepage: "https://example.com", + items: [{ name: "button", type: "registry:ui" }], + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination).toBeUndefined() + } + }) + + it("should reject registries with invalid pagination", () => { + const result = registrySchema.safeParse({ + name: "example", + homepage: "https://example.com", + items: [{ name: "button", type: "registry:ui" }], + pagination: { + total: "100", + }, + }) + + expect(result.success).toBe(false) + }) }) diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts index 7948ee35be7..c9b144e277b 100644 --- a/packages/shadcn/src/registry/schema.ts +++ b/packages/shadcn/src/registry/schema.ts @@ -198,6 +198,16 @@ export type RegistryBaseItem = Extract // Helper type for registry:font items specifically. export type RegistryFontItem = Extract +// Pagination metadata returned by registries that implement dynamic search. +// Its presence on a catalog response signals that the items are already +// filtered and paginated server-side. +export const registryPaginationSchema = z.object({ + total: z.number(), + offset: z.number(), + limit: z.number(), + hasMore: z.boolean(), +}) + const registryBaseSchema = z .object({ $schema: z.string().optional(), @@ -205,6 +215,7 @@ const registryBaseSchema = z homepage: z.string().optional(), include: z.array(z.string()).optional(), items: z.array(registryItemSchema).optional(), + pagination: registryPaginationSchema.optional(), }) .refine( (registry) => @@ -227,6 +238,7 @@ export const registrySchema = registryChunkSchema.pipe( homepage: z.string(), include: z.array(z.string()).optional(), items: z.array(registryItemSchema), + pagination: registryPaginationSchema.optional(), }) ) @@ -293,12 +305,7 @@ export const searchResultErrorSchema = z.object({ }) export const searchResultsSchema = z.object({ - pagination: z.object({ - total: z.number(), - offset: z.number(), - limit: z.number(), - hasMore: z.boolean(), - }), + pagination: registryPaginationSchema, items: z.array(searchResultItemSchema), // Registries that failed to load during the search. Only present when a // search tolerates per-registry failures (see searchRegistries' diff --git a/packages/shadcn/src/registry/search.test.ts b/packages/shadcn/src/registry/search.test.ts index ff11f1545dd..b4a0aff7d57 100644 --- a/packages/shadcn/src/registry/search.test.ts +++ b/packages/shadcn/src/registry/search.test.ts @@ -1087,3 +1087,296 @@ describe("findUnknownSearchTypes", () => { expect(findUnknownSearchTypes(["internal"])).toEqual(["internal"]) }) }) + +describe("searchRegistries with dynamic registries", () => { + it("forwards search params to the registry", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockResolvedValue({ + name: "acme", + homepage: "https://acme.com", + items: [], + }) + + await searchRegistries(["@acme"], { + query: "button", + types: ["ui", "registry:block"], + limit: 20, + offset: 40, + }) + + expect(mockGetRegistry).toHaveBeenCalledWith( + "@acme", + expect.objectContaining({ + searchParams: { + query: "button", + types: ["registry:ui", "registry:block"], + limit: 20, + offset: 40, + }, + }) + ) + + mockGetRegistry.mockRestore() + }) + + it("pushes down filters but not offset when searching multiple registries", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockResolvedValue({ + name: "acme", + homepage: "https://acme.com", + items: [], + }) + + await searchRegistries(["@one", "@two"], { + query: "button", + limit: 10, + offset: 20, + }) + + // Each registry is over-fetched (offset + limit) so the requested page + // can be filled after the merge. + for (const registry of ["@one", "@two"]) { + expect(mockGetRegistry).toHaveBeenCalledWith( + registry, + expect.objectContaining({ + searchParams: { + query: "button", + types: undefined, + limit: 30, + offset: undefined, + }, + }) + ) + } + + mockGetRegistry.mockRestore() + }) + + it("trusts server results when a single dynamic registry is searched", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + // The server returns items that would not match a local fuzzy search to + // prove no local filtering is applied on top. + mockGetRegistry.mockResolvedValue({ + name: "acme", + homepage: "https://acme.com", + items: [ + { + name: "unrelated-item", + type: "registry:ui", + description: "Does not mention the query.", + }, + ], + pagination: { + total: 500, + offset: 10, + limit: 1, + hasMore: true, + }, + }) + + const results = await searchRegistries(["@acme"], { + query: "button", + limit: 1, + offset: 10, + }) + + expect(results).toEqual({ + items: [ + { + name: "unrelated-item", + type: "registry:ui", + description: "Does not mention the query.", + registry: "@acme", + addCommandArgument: "@acme/unrelated-item", + }, + ], + pagination: { + total: 500, + offset: 10, + limit: 1, + hasMore: true, + }, + }) + + mockGetRegistry.mockRestore() + }) + + it("merges dynamic and static registries", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@dynamic") { + return { + name: "dynamic", + homepage: "https://dynamic.com", + items: [ + { + name: "button", + type: "registry:ui", + description: "A server-filtered button.", + }, + ], + pagination: { + total: 42, + offset: 0, + limit: 1, + hasMore: true, + }, + } + } + if (name === "@static") { + return { + name: "static", + homepage: "https://static.com", + items: [ + { + name: "button-group", + type: "registry:ui", + description: "A button group.", + }, + { + name: "card", + type: "registry:ui", + description: "A card component.", + }, + ], + } + } + throw new Error(`Unknown registry: ${name}`) + }) + + const results = await searchRegistries(["@dynamic", "@static"], { + query: "button", + }) + + // Server-filtered items are kept as-is. Static items still go through + // the local fuzzy filter, which drops "card". + expect(results.items).toEqual([ + { + name: "button", + type: "registry:ui", + description: "A server-filtered button.", + registry: "@dynamic", + addCommandArgument: "@dynamic/button", + }, + { + name: "button-group", + type: "registry:ui", + description: "A button group.", + registry: "@static", + addCommandArgument: "@static/button-group", + }, + ]) + + // The dynamic registry's total includes matches beyond the returned + // items. No limit was requested, so the identical request would be sent + // again for a deeper page — the tail is unreachable and hasMore is false. + expect(results.pagination.total).toBe(43) + expect(results.pagination.hasMore).toBe(false) + + mockGetRegistry.mockRestore() + }) + + it("reports more pages when a dynamic registry fills the requested limit", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@dynamic") { + return { + name: "dynamic", + homepage: "https://dynamic.com", + items: [ + { name: "button", type: "registry:ui" }, + { name: "button-group", type: "registry:ui" }, + ], + pagination: { + total: 42, + offset: 0, + limit: 2, + hasMore: true, + }, + } + } + return { + name: "static", + homepage: "https://static.com", + items: [], + } + }) + + const results = await searchRegistries(["@dynamic", "@static"], { + query: "button", + limit: 2, + }) + + // The registry filled the requested limit, so deeper pages re-request it + // with a larger limit and can surface the remaining matches. + expect(results.items).toHaveLength(2) + expect(results.pagination.total).toBe(42) + expect(results.pagination.hasMore).toBe(true) + + mockGetRegistry.mockRestore() + }) + + it("does not report more pages when a dynamic registry caps its response", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + // The registry claims 500 matches but caps every response at one item, + // regardless of the requested limit. + mockGetRegistry.mockResolvedValue({ + name: "capped", + homepage: "https://capped.com", + items: [{ name: "item-0", type: "registry:ui" }], + pagination: { + total: 500, + offset: 0, + limit: 1, + hasMore: true, + }, + }) + + const results = await searchRegistries(["@capped", "@static"], { + limit: 5, + offset: 10, + }) + + // The page beyond the cap is empty. hasMore must be false so paging + // stops instead of looping through empty pages toward total. + expect(results.items).toEqual([]) + expect(results.pagination.hasMore).toBe(false) + + mockGetRegistry.mockRestore() + }) + + it("keeps local pagination when no registry returns pagination", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockResolvedValue({ + name: "static", + homepage: "https://static.com", + items: [ + { name: "button", type: "registry:ui" }, + { name: "card", type: "registry:ui" }, + { name: "input", type: "registry:ui" }, + ], + }) + + const results = await searchRegistries(["@static"], { + limit: 2, + offset: 1, + }) + + expect(results.items.map((item) => item.name)).toEqual(["card", "input"]) + expect(results.pagination).toEqual({ + total: 3, + offset: 1, + limit: 2, + hasMore: false, + }) + + mockGetRegistry.mockRestore() + }) +}) diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts index 2f0665ecb7a..11f8dc07352 100644 --- a/packages/shadcn/src/registry/search.ts +++ b/packages/shadcn/src/registry/search.ts @@ -1,5 +1,6 @@ import { registryItemTypeSchema, + registryPaginationSchema, searchResultErrorSchema, searchResultItemSchema, searchResultsSchema, @@ -103,18 +104,42 @@ async function searchRegistriesWithContext( continueOnError, } = options || {} - let allItems: z.infer[] = [] const errors: z.infer[] = [] + // Search params are forwarded to every registry so dynamic registries can + // filter server-side. Pagination only composes when a single registry is + // searched. Across multiple registries a global offset cannot be + // distributed, so we push down filters only and over-fetch enough items + // (offset + limit) from each registry to fill the requested page locally. + const isSingleRegistry = registries.length === 1 + const wireTypes = types?.map((type) => toRegistryItemType(type)) + const searchParams = isSingleRegistry + ? { query, types: wireTypes, limit, offset } + : { + query, + types: wireTypes, + limit: limit !== undefined ? (offset || 0) + limit : undefined, + offset: undefined, + } + // Fetch registries concurrently (capped), then process the results in the // original order so the output is deterministic regardless of which // responses land first. This matters most when searching many registries. const outcomes = await mapSettledWithConcurrency( registries, SEARCH_CONCURRENCY, - (registry) => getRegistry(registry, { config, useCache }) + (registry) => getRegistry(registry, { config, useCache, searchParams }) ) + // A registry that returns `pagination` has already filtered and paginated + // its items server-side (see the dynamic search docs). Its items are kept + // as-is while items from static registries go through the local pipeline. + let localItems: z.infer[] = [] + const serverResults: { + items: z.infer[] + pagination: z.infer + }[] = [] + for (let index = 0; index < registries.length; index++) { const registry = registries[index] const outcome = outcomes[index] @@ -144,7 +169,25 @@ async function searchRegistriesWithContext( ), })) - allItems = allItems.concat(itemsWithRegistry) + if (outcome.value.pagination) { + serverResults.push({ + items: itemsWithRegistry, + pagination: outcome.value.pagination, + }) + continue + } + + localItems = localItems.concat(itemsWithRegistry) + } + + // Single dynamic registry: the server did all the work. Return its items + // and pagination verbatim so ranking and totals are the server's. + if (isSingleRegistry && serverResults.length === 1) { + const [serverResult] = serverResults + return searchResultsSchema.parse({ + pagination: serverResult.pagination, + items: serverResult.items, + }) } // Filter by type before the fuzzy query. Accepts both shorthand ("ui") and @@ -153,7 +196,7 @@ async function searchRegistriesWithContext( const wantedTypes = new Set( types.map((type) => formatSearchResultType(type).toLowerCase()) ) - allItems = allItems.filter( + localItems = localItems.filter( (item) => item.type && wantedTypes.has(formatSearchResultType(item.type).toLowerCase()) @@ -161,23 +204,48 @@ async function searchRegistriesWithContext( } if (query) { - allItems = searchItems(allItems, { + localItems = searchItems(localItems, { query, - limit: allItems.length, + limit: localItems.length, keys: ["name", "description"], }) as z.infer[] } + // Merge pre-filtered items (in registry order) with locally filtered items, + // then paginate the combined list. `total` includes matches a dynamic + // registry counted but did not return, so the match count stays accurate + // even when a registry truncated its response. + const allItems = serverResults + .flatMap((serverResult) => serverResult.items) + .concat(localItems) + const serverTotal = serverResults.reduce( + (sum, serverResult) => sum + serverResult.pagination.total, + 0 + ) + const paginationOffset = offset || 0 const paginationLimit = limit || allItems.length - const totalItems = allItems.length + const totalItems = serverTotal + localItems.length + + // Deeper pages re-request every registry with a larger over-fetch limit, so + // a dynamic registry can serve them only if it filled the current request. + // A registry that returned fewer items than requested is capped and its + // remaining matches are unreachable through paging — it must not drive + // `hasMore`, or paging would loop through empty pages forever. + const serverHasMore = serverResults.some( + (serverResult) => + serverResult.pagination.hasMore && + searchParams.limit !== undefined && + serverResult.items.length >= searchParams.limit + ) const result: z.infer = { pagination: { total: totalItems, offset: paginationOffset, limit: paginationLimit, - hasMore: paginationOffset + paginationLimit < totalItems, + hasMore: + paginationOffset + paginationLimit < allItems.length || serverHasMore, }, items: allItems.slice(paginationOffset, paginationOffset + paginationLimit), // Only surface errors when present so consumers parsing successful @@ -316,6 +384,12 @@ export function formatSearchResultType(type?: string) { return type.startsWith("registry:") ? type.slice("registry:".length) : type } +// Inverse of formatSearchResultType. Normalizes a type filter to the full +// namespaced form for the wire, e.g. "ui" -> "registry:ui". +export function toRegistryItemType(type: string) { + return type.startsWith("registry:") ? type : `registry:${type}` +} + // Internal-only types that should not be offered as a --type filter. const INTERNAL_TYPES = ["registry:example", "registry:internal"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2fefa5b34a..db0bb755de5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: specifier: ^0.0.1 version: 0.0.1 shadcn: - specifier: 4.16.0 + specifier: 4.16.1 version: link:../../packages/shadcn shiki: specifier: ^3.23.0