feat: refactor registryDependencies resolution (#7720)

* feat(shadcn): refactor registry dependencies resolution

* chore: changeset

* fix

* style: fix some code style
This commit is contained in:
shadcn
2025-07-01 17:56:50 +04:00
committed by GitHub
parent 48fe0d709f
commit d544a7f7a5
3 changed files with 210 additions and 51 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
refactor registry dependencies resolution

View File

@@ -13,7 +13,12 @@ import {
vi,
} from "vitest"
import { clearRegistryCache, fetchRegistry, getRegistryItem } from "./api"
import {
clearRegistryCache,
fetchRegistry,
getRegistryItem,
registryResolveItemsTree,
} from "./api"
// Mock the handleError function to prevent process.exit in tests
vi.mock("@/src/utils/handle-error", () => ({
@@ -309,4 +314,117 @@ describe("getRegistryItem with local files", () => {
type: "registry:ui",
})
})
it("should handle local files with URL dependencies", async () => {
// Mock a URL endpoint for dependency
const dependencyUrl = "https://example.com/dependency.json"
server.use(
http.get(dependencyUrl, () => {
return HttpResponse.json({
name: "url-dependency",
type: "registry:ui",
files: [
{
path: "ui/url-dependency.tsx",
content: "// url dependency content",
type: "registry:ui",
},
],
})
})
)
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "component-with-url-deps.json")
const componentData = {
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl, "button"], // Mix of URL and registry name
files: [
{
path: "ui/component-with-url-deps.tsx",
content: "// component with url deps content",
type: "registry:ui",
},
],
}
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toMatchObject({
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl, "button"],
})
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
})
describe("registryResolveItemsTree with URL dependencies", () => {
it("should resolve URL dependencies from local files", async () => {
// Mock a URL endpoint for dependency
const dependencyUrl = "https://example.com/dependency.json"
server.use(
http.get(dependencyUrl, () => {
return HttpResponse.json({
name: "url-dependency",
type: "registry:ui",
files: [
{
path: "ui/url-dependency.tsx",
content: "// url dependency content",
type: "registry:ui",
},
],
})
})
)
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "component-with-url-deps.json")
const componentData = {
name: "component-with-url-deps",
type: "registry:ui",
registryDependencies: [dependencyUrl], // URL dependency
files: [
{
path: "ui/component-with-url-deps.tsx",
content: "// component with url deps content",
type: "registry:ui",
},
],
}
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const mockConfig = {
style: "new-york",
tailwind: { baseColor: "neutral", cssVariables: true },
resolvedPaths: { cwd: process.cwd() },
} as any
const result = await registryResolveItemsTree([tempFile], mockConfig)
expect(result).toBeDefined()
expect(result?.files).toBeDefined()
// Should contain files from both the main component and its URL dependency
const filePaths = result?.files?.map((f: any) => f.path) ?? []
expect(filePaths).toContain("ui/component-with-url-deps.tsx")
expect(filePaths).toContain("ui/url-dependency.tsx")
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
})

View File

@@ -292,12 +292,78 @@ export function clearRegistryCache() {
registryCache.clear()
}
async function resolveDependenciesRecursively(
dependencies: string[],
config?: Config,
visited: Set<string> = new Set()
): Promise<{
items: z.infer<typeof registryItemSchema>[]
registryNames: string[]
}> {
const items: z.infer<typeof registryItemSchema>[] = []
const registryNames: string[] = []
for (const dep of dependencies) {
// Avoid infinite recursion.
if (visited.has(dep)) {
continue
}
visited.add(dep)
if (isUrl(dep) || isLocalFile(dep)) {
const item = await getRegistryItem(dep, "")
if (item) {
items.push(item)
if (item.registryDependencies) {
const nested = await resolveDependenciesRecursively(
item.registryDependencies,
config,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
}
} else {
// Registry name - add it to the list
registryNames.push(dep)
// If we have config, we can also fetch the item to get its dependencies
if (config) {
const style = config.resolvedPaths?.cwd
? await getTargetStyleFromConfig(
config.resolvedPaths.cwd,
config.style
)
: config.style
try {
const item = await getRegistryItem(dep, style)
if (item && item.registryDependencies) {
const nested = await resolveDependenciesRecursively(
item.registryDependencies,
config,
visited
)
items.push(...nested.items)
registryNames.push(...nested.registryNames)
}
} catch (error) {
// If we can't fetch the registry item, that's okay - we'll still include the name
}
}
}
}
return { items, registryNames }
}
export async function registryResolveItemsTree(
names: z.infer<typeof registryItemSchema>["name"][],
config: Config
) {
try {
// Separate local files, URLs, and registry names
// Separate local files, URLs, and registry names.
const localFiles = names.filter((name) => isLocalFile(name))
const urls = names.filter((name) => isUrl(name))
const registryNames = names.filter(
@@ -306,49 +372,46 @@ export async function registryResolveItemsTree(
const payload: z.infer<typeof registryItemSchema>[] = []
// Handle local files directly and collect their registry dependencies
const registryDependenciesFromLocalFiles: string[] = []
// Handle local files and URLs directly, collecting their dependencies.
const allDependencies: string[] = []
for (const localFile of localFiles) {
const item = await getRegistryItem(localFile, "")
if (item) {
payload.push(item)
// Collect registry dependencies from local files
if (item.registryDependencies) {
registryDependenciesFromLocalFiles.push(...item.registryDependencies)
allDependencies.push(...item.registryDependencies)
}
}
}
// Handle URLs directly and collect their registry dependencies
const registryDependenciesFromUrls: string[] = []
for (const url of urls) {
const item = await getRegistryItem(url, "")
if (item) {
payload.push(item)
// Collect registry dependencies from URLs
if (item.registryDependencies) {
registryDependenciesFromUrls.push(...item.registryDependencies)
allDependencies.push(...item.registryDependencies)
}
}
}
// Combine all registry names (original + dependencies from local files/URLs)
const allRegistryNames = [
...registryNames,
...registryDependenciesFromLocalFiles,
...registryDependenciesFromUrls,
]
// Recursively resolve all dependencies.
const { items: dependencyItems, registryNames: dependencyRegistryNames } =
await resolveDependenciesRecursively(allDependencies, config)
// Handle registry names with existing logic
payload.push(...dependencyItems)
// Handle registry names using existing resolveRegistryItems logic.
const allRegistryNames = [...registryNames, ...dependencyRegistryNames]
if (allRegistryNames.length > 0) {
const index = await getRegistryIndex()
if (!index) {
// If we only have local files or URLs, that's fine
// If we only have local files or URLs, that's fine.
if (payload.length === 0) {
return null
}
} else {
// Remove duplicates
// Remove duplicates.
const uniqueRegistryNames = Array.from(new Set(allRegistryNames))
// If we're resolving the index, we want it to go first.
@@ -436,44 +499,17 @@ async function resolveRegistryDependencies(
url: string,
config: Config
): Promise<string[]> {
const visited = new Set<string>()
const payload: string[] = []
const { registryNames } = await resolveDependenciesRecursively([url], config)
const style = config.resolvedPaths?.cwd
? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style)
: config.style
async function resolveDependencies(itemUrl: string) {
const url = getRegistryUrl(
isUrl(itemUrl) ? itemUrl : `styles/${style}/${itemUrl}.json`
)
const urls = registryNames.map((name) =>
getRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`)
)
if (visited.has(url)) {
return
}
visited.add(url)
try {
const [result] = await fetchRegistry([url])
const item = registryItemSchema.parse(result)
payload.push(url)
if (item.registryDependencies) {
for (const dependency of item.registryDependencies) {
await resolveDependencies(dependency)
}
}
} catch (error) {
console.error(
`Error fetching or parsing registry item at ${itemUrl}:`,
error
)
}
}
await resolveDependencies(url)
return Array.from(new Set(payload))
return Array.from(new Set(urls))
}
export async function registryGetTheme(name: string, config: Config) {