mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-29 15:44:22 +00:00
feat(shadcn): update search results format (#8003)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user