mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-29 07:34:11 +00:00
feat: refactor registryDependencies resolution (#7720)
* feat(shadcn): refactor registry dependencies resolution * chore: changeset * fix * style: fix some code style
This commit is contained in:
5
.changeset/beige-shirts-drop.md
Normal file
5
.changeset/beige-shirts-drop.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
refactor registry dependencies resolution
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user