feat(shadcn): update search results format (#8003)

This commit is contained in:
shadcn
2025-08-11 15:52:02 +04:00
committed by GitHub
parent fed7e3bfdc
commit 2e34c95c4e
3 changed files with 234 additions and 9 deletions

View File

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

View File

@@ -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<typeof searchResultItemSchema>[]
}
// 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<typeof searchResultsSchema> = {
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
}

View File

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