diff --git a/packages/shadcn/src/registry/search.test.ts b/packages/shadcn/src/registry/search.test.ts index 08ba4c6880..e9a49bd46a 100644 --- a/packages/shadcn/src/registry/search.test.ts +++ b/packages/shadcn/src/registry/search.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { getRegistry } from "./api" -import { searchRegistries } from "./search" +import { buildRegistryItemNameFromRegistry, searchRegistries } from "./search" describe("searchRegistries", () => { it("should fetch and return registries in flat format", async () => { @@ -56,18 +56,21 @@ describe("searchRegistries", () => { type: "registry:ui", description: "A button component", registry: "@shadcn", + addCommandArgument: "@shadcn/button", }, { name: "card", type: "registry:ui", description: "A card component", registry: "@shadcn", + addCommandArgument: "@shadcn/card", }, { name: "header", type: "registry:component", description: "A header component", registry: "@custom", + addCommandArgument: "@custom/header", }, ], pagination: { @@ -120,6 +123,7 @@ describe("searchRegistries", () => { expect(results.items).toHaveLength(1) expect(results.items[0].name).toBe("button") expect(results.items[0].registry).toBe("@shadcn") + expect(results.items[0].addCommandArgument).toBe("@shadcn/button") expect(results.pagination).toEqual({ total: 1, offset: 0, @@ -530,3 +534,122 @@ describe("searchRegistries", () => { mockGetRegistry.mockRestore() }) }) + +describe("buildRegistryItemNameFromRegistry", () => { + const testCases = [ + // Namespace registries + { + name: "namespace registry", + itemName: "button", + registry: "@shadcn", + expected: "@shadcn/button", + }, + { + name: "namespace registry with org", + itemName: "card", + registry: "@myorg", + expected: "@myorg/card", + }, + + // URL with registry in path + { + name: "URL with registry.json", + itemName: "button", + registry: "http://example.com/r/registry.json", + expected: "http://example.com/r/button.json", + }, + { + name: "URL with multiple registry in path - replaces last", + itemName: "button", + registry: "http://example.com/registry/foo/registry", + expected: "http://example.com/registry/foo/button", + }, + { + name: "URL with registry in nested path", + itemName: "dialog", + registry: "http://example.com/components/registry/index.json", + expected: "http://example.com/components/dialog/index.json", + }, + + // URL with registry in query params + { + name: "URL with registry in query param", + itemName: "modal", + registry: "http://registry.foo.com?item=registry", + expected: "http://registry.foo.com?item=modal", + }, + { + name: "URL with registry in query param (multiple params)", + itemName: "tabs", + registry: "http://api.example.com/fetch?name=registry&type=component", + expected: "http://api.example.com/fetch?name=tabs&type=component", + }, + { + name: "URL with registry in both path and query", + itemName: "button", + registry: "http://example.com/registry?name=registry", + expected: "http://example.com/button?name=button", + }, + + // Edge cases - should NOT replace in domain/subdomain + { + name: "URL with registry in subdomain - should NOT replace", + itemName: "button", + registry: "http://registry.example.com/api", + expected: "http://registry.example.com/api", + }, + { + name: "URL with registry in domain - should NOT replace", + itemName: "button", + registry: "http://myregistry.com/api", + expected: "http://myregistry.com/api", + }, + + // URLs without registry + { + name: "URL without registry word", + itemName: "button", + registry: "http://example.com/components/all", + expected: "http://example.com/components/all", + }, + { + name: "URL with only query params, no registry", + itemName: "button", + registry: "http://example.com?type=ui", + expected: "http://example.com?type=ui", + }, + + // HTTPS and ports + { + name: "HTTPS URL with registry", + itemName: "sidebar", + registry: "https://secure.example.com/components/registry", + expected: "https://secure.example.com/components/sidebar", + }, + { + name: "URL with port and registry", + itemName: "header", + registry: "http://localhost:3000/api/registry", + expected: "http://localhost:3000/api/header", + }, + + // Complex cases + { + name: "URL with hash and registry", + itemName: "footer", + registry: "http://example.com/registry#latest", + expected: "http://example.com/footer#latest", + }, + { + name: "URL with encoded characters", + itemName: "button", + registry: "http://example.com/registry%20component", + expected: "http://example.com/button%20component", + }, + ] + + it.each(testCases)("$name", ({ itemName, registry, expected }) => { + const result = buildRegistryItemNameFromRegistry(itemName, registry) + expect(result).toBe(expected) + }) +}) diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts index c1e8339e7c..e802089c2f 100644 --- a/packages/shadcn/src/registry/search.ts +++ b/packages/shadcn/src/registry/search.ts @@ -9,6 +9,7 @@ const searchResultItemSchema = z.object({ type: z.string().optional(), description: z.string().optional(), registry: z.string(), + addCommandArgument: z.string(), }) const searchResultsSchema = z.object({ @@ -38,32 +39,32 @@ export async function searchRegistries( for (const registry of registries) { const registryData = await getRegistry(registry, { config, useCache }) - const itemsWithRegistry = (registryData.items || []).map((item: any) => ({ + const itemsWithRegistry = (registryData.items || []).map((item) => ({ name: item.name, type: item.type, description: item.description, registry: registry, + addCommandArgument: buildRegistryItemNameFromRegistry( + item.name, + registry + ), })) allItems = allItems.concat(itemsWithRegistry) } - // Apply search if query is provided if (query) { allItems = searchItems(allItems, { query, - // No limit here - we want to search all items then paginate limit: allItems.length, keys: ["name", "description"], }) as z.infer[] } - // Apply offset and limit pagination const paginationOffset = offset || 0 const paginationLimit = limit || allItems.length const totalItems = allItems.length - // Build result with pagination const result: z.infer = { pagination: { total: totalItems, @@ -82,7 +83,8 @@ const searchableItemSchema = z name: z.string(), type: z.string().optional(), description: z.string().optional(), - registry: z.string().optional(), // Optional for backward compatibility + registry: z.string().optional(), + addCommandArgument: z.string().optional(), }) .passthrough() @@ -93,6 +95,7 @@ function searchItems< name: string type?: string description?: string + addCommandArgument?: string [key: string]: any } = SearchableItem >( @@ -117,3 +120,72 @@ function searchItems< return z.array(searchableItemSchema).parse(results) } + +function isUrl(string: string): boolean { + try { + new URL(string) + return true + } catch { + return false + } +} + +// Builds the registry item name for the add command. +// For namespaced registries, returns "registry/item". +// For URL registries, replaces "registry" with the item name in the URL. +export function buildRegistryItemNameFromRegistry( + name: string, + registry: string +) { + // If registry is not a URL, return namespace format. + if (!isUrl(registry)) { + return `${registry}/${name}` + } + + // Find where the host part ends in the original string. + const protocolEnd = registry.indexOf("://") + 3 + const hostEnd = registry.indexOf("/", protocolEnd) + + if (hostEnd === -1) { + // No path, check for query params. + const queryStart = registry.indexOf("?", protocolEnd) + if (queryStart !== -1) { + // Has query params but no path. + const beforeQuery = registry.substring(0, queryStart) + const queryAndAfter = registry.substring(queryStart) + // Replace "registry" with itemName in query params only. + const updatedQuery = queryAndAfter.replace(/\bregistry\b/g, name) + return beforeQuery + updatedQuery + } + // No path or query, return as is. + return registry + } + + // Split at host boundary. + const hostPart = registry.substring(0, hostEnd) + const pathAndQuery = registry.substring(hostEnd) + + // Find all occurrences of "registry" in path and query. + // Replace only the last occurrence in the path segment. + const pathEnd = + pathAndQuery.indexOf("?") !== -1 + ? pathAndQuery.indexOf("?") + : pathAndQuery.length + const pathOnly = pathAndQuery.substring(0, pathEnd) + const queryAndAfter = pathAndQuery.substring(pathEnd) + + // Replace the last occurrence of "registry" in the path. + const lastIndex = pathOnly.lastIndexOf("registry") + let updatedPath = pathOnly + if (lastIndex !== -1) { + updatedPath = + pathOnly.substring(0, lastIndex) + + name + + pathOnly.substring(lastIndex + "registry".length) + } + + // Replace all occurrences of "registry" in query params. + const updatedQuery = queryAndAfter.replace(/\bregistry\b/g, name) + + return hostPart + updatedPath + updatedQuery +} diff --git a/packages/tests/src/tests/search.test.ts b/packages/tests/src/tests/search.test.ts index c00e55d858..fe0c6a2a93 100644 --- a/packages/tests/src/tests/search.test.ts +++ b/packages/tests/src/tests/search.test.ts @@ -197,18 +197,21 @@ describe("shadcn search", () => { type: "registry:ui", description: "A button component", registry: "@shadcn", + addCommandArgument: "@shadcn/button", }, { name: "card", type: "registry:ui", description: "A card component", registry: "@shadcn", + addCommandArgument: "@shadcn/card", }, { name: "alert-dialog", type: "registry:ui", description: undefined, registry: "@shadcn", + addCommandArgument: "@shadcn/alert-dialog", }, ]) ) @@ -257,11 +260,13 @@ describe("shadcn search", () => { type: "registry:component", description: "Foo component from registry one", registry: "@one", + addCommandArgument: "@one/foo", }, { name: "bar", type: "registry:component", registry: "@one", + addCommandArgument: "@one/bar", }, ]) ) @@ -952,7 +957,7 @@ describe("shadcn search", () => { expect(parsed3.pagination.hasMore).toBe(false) }) - it("should list with only name, type, and description fields", async () => { + it("should list with only name, type, description, registry, and addCommandArgument fields", async () => { const fixturePath = await createFixtureTestDirectory("next-app-init") await configureRegistries(fixturePath, { @@ -962,12 +967,13 @@ describe("shadcn search", () => { const output = await npxShadcn(fixturePath, ["search", "@one"]) const parsed = JSON.parse(output.stdout) - // Check that we only get name, type, description, and registry fields + // Check that we only get name, type, description, registry, and addCommand fields expect(parsed.items).toContainEqual({ name: "foo", type: "registry:component", description: "Foo component from registry one", registry: "@one", + addCommandArgument: "@one/foo", }) // Verify that other fields are not included @@ -978,4 +984,28 @@ describe("shadcn search", () => { expect(fooItem.tailwind).toBeUndefined() expect(fooItem.cssVars).toBeUndefined() }) + + it("should handle different registry URL patterns for addCommand", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + "@two": "http://localhost:9182/registry/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@one", "@two"]) + const parsed = JSON.parse(output.stdout) + + // Check @one registry items have correct addCommand + const fooItem = parsed.items.find( + (item: any) => item.name === "foo" && item.registry === "@one" + ) + expect(fooItem.addCommandArgument).toBe("@one/foo") + + // Check @two registry items have correct addCommand + const itemItem = parsed.items.find( + (item: any) => item.name === "item" && item.registry === "@two" + ) + expect(itemItem.addCommandArgument).toBe("@two/item") + }) })