mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-30 08:04:18 +00:00
refactor(shadcn): add getRegistryItems and resolveRegistryItems (#7983)
* feat(shadcn): refactor fetchFromRegistry * refactor(shadcn): better api * chore: changeset * fix * fix * refactor * refactor(shadcn): update getRegistryItems * refactor(shadcn): error handling * fix: getRegistryItems header context * fix: tests * feat(shadcn): export errors * refactor(shadcn): getRegistryItems getRegistry * fix * fix * fix * fix * chore: changeset * chore: remove minor changeset
This commit is contained in:
5
.changeset/good-drinks-repeat.md
Normal file
5
.changeset/good-drinks-repeat.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": major
|
||||
---
|
||||
|
||||
update getRegistry, getRegistryItems and resolveRegistryItems apis
|
||||
@@ -1,7 +1,8 @@
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { preFlightAdd } from "@/src/preflights/preflight-add"
|
||||
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
|
||||
import { getRegistryItems, getShadcnRegistryIndex } from "@/src/registry/api"
|
||||
import { DEPRECATED_COMPONENTS } from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { isUniversalRegistryItem } from "@/src/registry/utils"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
@@ -18,21 +19,6 @@ import { Command } from "commander"
|
||||
import prompts from "prompts"
|
||||
import { z } from "zod"
|
||||
|
||||
const DEPRECATED_COMPONENTS = [
|
||||
{
|
||||
name: "toast",
|
||||
deprecatedBy: "sonner",
|
||||
message:
|
||||
"The toast component is deprecated. Use the sonner component instead.",
|
||||
},
|
||||
{
|
||||
name: "toaster",
|
||||
deprecatedBy: "sonner",
|
||||
message:
|
||||
"The toaster component is deprecated. Use the sonner component instead.",
|
||||
},
|
||||
]
|
||||
|
||||
export const addOptionsSchema = z.object({
|
||||
components: z.array(z.string()).optional(),
|
||||
yes: z.boolean(),
|
||||
@@ -91,7 +77,10 @@ export const add = new Command()
|
||||
}
|
||||
|
||||
if (components.length > 0) {
|
||||
const registryItem = await getRegistryItem(components[0], initialConfig)
|
||||
const [registryItem] = await getRegistryItems(
|
||||
[components[0]],
|
||||
initialConfig
|
||||
)
|
||||
const itemType = registryItem?.type
|
||||
|
||||
if (isUniversalRegistryItem(registryItem)) {
|
||||
@@ -236,7 +225,7 @@ export const add = new Command()
|
||||
async function promptForRegistryComponents(
|
||||
options: z.infer<typeof addOptionsSchema>
|
||||
) {
|
||||
const registryIndex = await getRegistryIndex()
|
||||
const registryIndex = await getShadcnRegistryIndex()
|
||||
if (!registryIndex) {
|
||||
logger.break()
|
||||
handleError(new Error("Failed to fetch registry index."))
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
fetchTree,
|
||||
getItemTargetPath,
|
||||
getRegistryBaseColor,
|
||||
getRegistryIndex,
|
||||
getShadcnRegistryIndex,
|
||||
} from "@/src/registry/api"
|
||||
import { registryIndexSchema } from "@/src/registry/schema"
|
||||
import { Config, getConfig } from "@/src/utils/get-config"
|
||||
@@ -57,7 +57,7 @@ export const diff = new Command()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const registryIndex = await getRegistryIndex()
|
||||
const registryIndex = await getShadcnRegistryIndex()
|
||||
|
||||
if (!registryIndex) {
|
||||
handleError(new Error("Failed to fetch registry index."))
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { preFlightInit } from "@/src/preflights/preflight-init"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry"
|
||||
import {
|
||||
BASE_COLORS,
|
||||
getRegistryBaseColors,
|
||||
getRegistryItem,
|
||||
getRegistryItems,
|
||||
getRegistryStyles,
|
||||
} from "@/src/registry/api"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { BASE_COLORS } from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { rawConfigSchema } from "@/src/registry/schema"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
@@ -152,12 +153,7 @@ export const init = new Command()
|
||||
if (components.length > 0) {
|
||||
// We don't know the full config at this point.
|
||||
// So we'll use a shadow config to fetch the first item.
|
||||
let shadowConfig: Parameters<typeof getRegistryItem>[1] = {
|
||||
style: "new-york",
|
||||
resolvedPaths: {
|
||||
cwd: "",
|
||||
},
|
||||
}
|
||||
let shadowConfig = configWithDefaults({})
|
||||
|
||||
// Check if there's a components.json file.
|
||||
// If so, we'll merge with our shadow config.
|
||||
@@ -165,10 +161,7 @@ export const init = new Command()
|
||||
if (fsExtra.existsSync(componentsJsonPath)) {
|
||||
const existingConfig = await fsExtra.readJson(componentsJsonPath)
|
||||
const config = rawConfigSchema.partial().parse(existingConfig)
|
||||
shadowConfig = {
|
||||
...shadowConfig,
|
||||
...config,
|
||||
}
|
||||
shadowConfig = configWithDefaults(config)
|
||||
|
||||
// Since components.json might not be valid at this point.
|
||||
// Temporarily rename components.json to allow preflight to run.
|
||||
@@ -179,7 +172,7 @@ export const init = new Command()
|
||||
// This forces a shadowConfig validation early in the process.
|
||||
buildUrlAndHeadersForRegistryItem(components[0], shadowConfig)
|
||||
|
||||
const item = await getRegistryItem(components[0], shadowConfig)
|
||||
const [item] = await getRegistryItems([components[0]], shadowConfig)
|
||||
if (item?.type === "registry:style") {
|
||||
// Set a default base color so we're not prompted.
|
||||
// The style will extend or override it.
|
||||
@@ -204,6 +197,9 @@ export const init = new Command()
|
||||
"Success!"
|
||||
)} Project initialization completed.\nYou may now add components.`
|
||||
)
|
||||
|
||||
// We need when runninng with custom cwd.
|
||||
deleteFileBackup(path.resolve(options.cwd, "components.json"))
|
||||
logger.break()
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { registrySchema } from "@/src/registry"
|
||||
import { fetchRegistry, getRegistryItem } from "@/src/registry/api"
|
||||
import { getRegistryItems } from "@/src/registry/api"
|
||||
import { fetchRegistry } from "@/src/registry/fetcher"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -197,8 +198,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error("Name is required")
|
||||
}
|
||||
|
||||
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
|
||||
const item = await getRegistryItem(itemUrl)
|
||||
const [item] = await getRegistryItems([name], undefined, {
|
||||
useCache: false,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(item, null, 2) }],
|
||||
@@ -213,7 +215,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
|
||||
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
|
||||
const item = await getRegistryItem(itemUrl)
|
||||
const item = await getRegistryItems([itemUrl], undefined, {
|
||||
useCache: false,
|
||||
})
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import {
|
||||
RegistryErrorCode,
|
||||
RegistryFetchError,
|
||||
RegistryForbiddenError,
|
||||
RegistryLocalFileError,
|
||||
RegistryNotConfiguredError,
|
||||
RegistryNotFoundError,
|
||||
RegistryParseError,
|
||||
RegistryUnauthorizedError,
|
||||
} from "@/src/registry/errors"
|
||||
import { HttpResponse, http } from "msw"
|
||||
import { setupServer } from "msw/node"
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest"
|
||||
|
||||
import {
|
||||
clearRegistryCache,
|
||||
fetchRegistry,
|
||||
getRegistry,
|
||||
getRegistryItem,
|
||||
registryResolveItemsTree,
|
||||
} from "./api"
|
||||
import { getRegistry, getRegistryItems } from "./api"
|
||||
|
||||
// Mock the handleError function to prevent process.exit in tests
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock the logger to prevent console output in tests
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
@@ -36,8 +38,6 @@ vi.mock("@/src/utils/logger", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const REGISTRY_URL = process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
|
||||
|
||||
const server = setupServer(
|
||||
http.get(`${REGISTRY_URL}/index.json`, () => {
|
||||
return HttpResponse.json([
|
||||
@@ -65,6 +65,21 @@ const server = setupServer(
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/button.json`, () => {
|
||||
return HttpResponse.json({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
files: [
|
||||
{
|
||||
path: "registry/new-york/ui/button.tsx",
|
||||
content: "// button component content v4",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
http.get(`${REGISTRY_URL}/styles/new-york/card.json`, () => {
|
||||
return HttpResponse.json({
|
||||
name: "card",
|
||||
@@ -87,77 +102,7 @@ afterEach(() => {
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe("fetchRegistry", () => {
|
||||
it("should fetch registry data", async () => {
|
||||
const paths = ["styles/new-york/button.json"]
|
||||
const result = await fetchRegistry(paths)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should use cache for subsequent requests", async () => {
|
||||
const paths = ["styles/new-york/button.json"]
|
||||
let fetchCount = 0
|
||||
|
||||
// Clear any existing cache before test
|
||||
clearRegistryCache()
|
||||
|
||||
// Define the handler with counter before making requests
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, async () => {
|
||||
// Add a small delay to simulate network latency
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
fetchCount++
|
||||
return HttpResponse.json({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
files: [
|
||||
{
|
||||
path: "registry/new-york/ui/button.tsx",
|
||||
content: "// button component content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// First request
|
||||
const result1 = await fetchRegistry(paths)
|
||||
expect(fetchCount).toBe(1)
|
||||
expect(result1).toHaveLength(1)
|
||||
expect(result1[0]).toMatchObject({ name: "button" })
|
||||
|
||||
// Second request - should use cache
|
||||
const result2 = await fetchRegistry(paths)
|
||||
expect(fetchCount).toBe(1) // Should still be 1
|
||||
expect(result2).toHaveLength(1)
|
||||
expect(result2[0]).toMatchObject({ name: "button" })
|
||||
|
||||
// Third request - double check cache
|
||||
const result3 = await fetchRegistry(paths)
|
||||
expect(fetchCount).toBe(1) // Should still be 1
|
||||
expect(result3).toHaveLength(1)
|
||||
expect(result3[0]).toMatchObject({ name: "button" })
|
||||
})
|
||||
|
||||
it("should handle multiple paths", async () => {
|
||||
const paths = ["styles/new-york/button.json", "styles/new-york/card.json"]
|
||||
const result = await fetchRegistry(paths)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ name: "button" })
|
||||
expect(result[1]).toMatchObject({ name: "card" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRegistryItem with local files", () => {
|
||||
describe("getRegistryItem", () => {
|
||||
it("should read and parse a valid local JSON file", async () => {
|
||||
// Create a temporary file
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
@@ -179,7 +124,7 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile)
|
||||
const [result] = await getRegistryItems([tempFile])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "test-component",
|
||||
@@ -217,7 +162,7 @@ describe("getRegistryItem with local files", () => {
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(tempDir)
|
||||
|
||||
const result = await getRegistryItem("./relative-component.json")
|
||||
const [result] = await getRegistryItems(["./relative-component.json"])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "relative-component",
|
||||
@@ -248,7 +193,7 @@ describe("getRegistryItem with local files", () => {
|
||||
try {
|
||||
// Test with tilde path
|
||||
const tildeePath = "~/shadcn-test-tilde.json"
|
||||
const result = await getRegistryItem(tildeePath)
|
||||
const [result] = await getRegistryItems([tildeePath])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "tilde-component",
|
||||
@@ -260,20 +205,20 @@ describe("getRegistryItem with local files", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("should return null for non-existent files", async () => {
|
||||
const result = await getRegistryItem("/non/existent/file.json")
|
||||
expect(result).toBe(null)
|
||||
it("should throw error for non-existent files", async () => {
|
||||
await expect(getRegistryItems(["/non/existent/file.json"])).rejects.toThrow(
|
||||
RegistryLocalFileError
|
||||
)
|
||||
})
|
||||
|
||||
it("should return null for invalid JSON", async () => {
|
||||
it("should throw error for invalid JSON", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "invalid.json")
|
||||
|
||||
await fs.writeFile(tempFile, "{ invalid json }")
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile)
|
||||
expect(result).toBe(null)
|
||||
await expect(getRegistryItems([tempFile])).rejects.toThrow()
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
@@ -281,7 +226,7 @@ describe("getRegistryItem with local files", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("should return null for JSON that doesn't match registry schema", async () => {
|
||||
it("should throw error for JSON that doesn't match registry schema", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "invalid-schema.json")
|
||||
|
||||
@@ -293,8 +238,9 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, JSON.stringify(invalidData))
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile)
|
||||
expect(result).toBe(null)
|
||||
await expect(getRegistryItems([tempFile])).rejects.toThrow(
|
||||
RegistryParseError
|
||||
)
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
@@ -304,7 +250,7 @@ describe("getRegistryItem with local files", () => {
|
||||
|
||||
it("should still handle URLs and component names", async () => {
|
||||
// Test that existing functionality still works
|
||||
const result = await getRegistryItem("button")
|
||||
const [result] = await getRegistryItems(["button"])
|
||||
expect(result).toMatchObject({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
@@ -349,7 +295,7 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile)
|
||||
const [result] = await getRegistryItems([tempFile])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "component-with-url-deps",
|
||||
@@ -362,144 +308,247 @@ describe("getRegistryItem with local files", () => {
|
||||
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"
|
||||
it("should include error code in RegistryNotFoundError", async () => {
|
||||
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",
|
||||
},
|
||||
],
|
||||
})
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/non-existent.json`, () => {
|
||||
return HttpResponse.json({ error: "Not found" }, { status: 404 })
|
||||
})
|
||||
)
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
try {
|
||||
await getRegistryItems(["non-existent"])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryNotFoundError)
|
||||
if (error instanceof RegistryNotFoundError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.NOT_FOUND)
|
||||
expect(error.statusCode).toBe(404)
|
||||
expect(error.suggestion).toContain("Check if the item name is correct")
|
||||
expect(error.url).toContain("non-existent.json")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
it("should include error code in RegistryUnauthorizedError", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/protected.json`, () => {
|
||||
return HttpResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: { cwd: process.cwd() },
|
||||
} as any
|
||||
await getRegistryItems(["protected"])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryUnauthorizedError)
|
||||
if (error instanceof RegistryUnauthorizedError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.UNAUTHORIZED)
|
||||
expect(error.statusCode).toBe(401)
|
||||
expect(error.suggestion).toContain(
|
||||
"Check your authentication credentials"
|
||||
)
|
||||
expect(error.url).toContain("protected.json")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await registryResolveItemsTree([tempFile], mockConfig)
|
||||
it("should include error code in RegistryForbiddenError", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/forbidden.json`, () => {
|
||||
return HttpResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
})
|
||||
)
|
||||
|
||||
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")
|
||||
try {
|
||||
await getRegistryItems(["forbidden"])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryForbiddenError)
|
||||
if (error instanceof RegistryForbiddenError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.FORBIDDEN)
|
||||
expect(error.statusCode).toBe(403)
|
||||
expect(error.suggestion).toContain(
|
||||
"Check your authentication credentials"
|
||||
)
|
||||
expect(error.url).toContain("forbidden.json")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should include error code in RegistryFetchError for 500 errors", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/server-error.json`, () => {
|
||||
return HttpResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await getRegistryItems(["server-error"])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryFetchError)
|
||||
if (error instanceof RegistryFetchError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.FETCH_ERROR)
|
||||
expect(error.statusCode).toBe(500)
|
||||
expect(error.suggestion).toContain(
|
||||
"The registry server encountered an error"
|
||||
)
|
||||
expect(error.url).toContain("server-error.json")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should extract Zod validation issues in RegistryParseError", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "invalid-schema.json")
|
||||
|
||||
const invalidData = {
|
||||
name: "test",
|
||||
// Missing required "type" field
|
||||
// Invalid "files" field (should be array)
|
||||
files: "not-an-array",
|
||||
}
|
||||
|
||||
await fs.writeFile(tempFile, JSON.stringify(invalidData))
|
||||
|
||||
try {
|
||||
await getRegistryItems([tempFile])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryParseError)
|
||||
if (error instanceof RegistryParseError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR)
|
||||
expect(error.message).toContain("Failed to parse registry item")
|
||||
expect(error.message).toContain(tempFile)
|
||||
// The message should include Zod validation issues
|
||||
expect(error.suggestion).toContain(
|
||||
"may be corrupted or have an invalid format"
|
||||
)
|
||||
expect(error.context?.item).toBe(tempFile)
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
await fs.rmdir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("should resolve namespace syntax in registryDependencies", async () => {
|
||||
// Mock a namespace registry endpoint
|
||||
const namespaceUrl = "https://custom-registry.com/custom-component.json"
|
||||
it("should include context in RegistryLocalFileError", async () => {
|
||||
const nonExistentFile = "/path/to/non/existent/file.json"
|
||||
|
||||
try {
|
||||
await getRegistryItems([nonExistentFile])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryLocalFileError)
|
||||
if (error instanceof RegistryLocalFileError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.LOCAL_FILE_ERROR)
|
||||
expect(error.filePath).toBe(nonExistentFile)
|
||||
expect(error.suggestion).toContain("Check if the file exists")
|
||||
expect(error.context?.filePath).toBe(nonExistentFile)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should serialize error to JSON correctly", async () => {
|
||||
server.use(
|
||||
http.get(namespaceUrl, () => {
|
||||
return HttpResponse.json({
|
||||
name: "custom-component",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "ui/custom-component.tsx",
|
||||
content: "// custom component content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/test-json.json`, () => {
|
||||
return HttpResponse.json({ error: "Not found" }, { status: 404 })
|
||||
})
|
||||
)
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "component-with-namespace-deps.json")
|
||||
|
||||
const componentData = {
|
||||
name: "component-with-namespace-deps",
|
||||
type: "registry:ui",
|
||||
registryDependencies: ["@custom/custom-component"], // Namespace dependency
|
||||
files: [
|
||||
{
|
||||
path: "ui/component-with-namespace-deps.tsx",
|
||||
content: "// component with namespace deps content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
try {
|
||||
await getRegistryItems(["test-json"])
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryNotFoundError) {
|
||||
const json = error.toJSON()
|
||||
expect(json).toHaveProperty("name", "RegistryNotFoundError")
|
||||
expect(json).toHaveProperty("message")
|
||||
expect(json).toHaveProperty("code", RegistryErrorCode.NOT_FOUND)
|
||||
expect(json).toHaveProperty("statusCode", 404)
|
||||
expect(json).toHaveProperty("context")
|
||||
expect(json).toHaveProperty("suggestion")
|
||||
expect(json).toHaveProperty("timestamp")
|
||||
expect(json).toHaveProperty("stack")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
it("should include timestamp in errors", async () => {
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/timestamp-test.json`, () => {
|
||||
return HttpResponse.json({ error: "Not found" }, { status: 404 })
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: { cwd: process.cwd() },
|
||||
registries: {
|
||||
"@custom": {
|
||||
url: "https://custom-registry.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
await getRegistryItems(["timestamp-test"])
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryNotFoundError) {
|
||||
expect(error.timestamp).toBeInstanceOf(Date)
|
||||
expect(error.timestamp.getTime()).toBeLessThanOrEqual(Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await registryResolveItemsTree([tempFile], mockConfig)
|
||||
it("should handle multiple validation errors in RegistryParseError", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "multiple-errors.json")
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.files).toBeDefined()
|
||||
const invalidData = {
|
||||
// Missing name and type
|
||||
files: 123, // Should be array
|
||||
dependencies: "not-an-array", // Should be array
|
||||
}
|
||||
|
||||
expect(result?.files?.length).toBe(2)
|
||||
expect(
|
||||
result?.files?.some((f) => f.path === "ui/custom-component.tsx")
|
||||
).toBe(true)
|
||||
expect(
|
||||
result?.files?.some(
|
||||
(f) => f.path === "ui/component-with-namespace-deps.tsx"
|
||||
)
|
||||
).toBe(true)
|
||||
await fs.writeFile(tempFile, JSON.stringify(invalidData))
|
||||
|
||||
try {
|
||||
await getRegistryItems([tempFile])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryParseError)
|
||||
if (error instanceof RegistryParseError) {
|
||||
// The error message should contain multiple validation issues
|
||||
expect(error.message).toContain("Failed to parse registry item")
|
||||
// Check that context contains validation issues
|
||||
if (error.context && "validationIssues" in error.context) {
|
||||
const issues = error.context.validationIssues as Array<{
|
||||
path: string
|
||||
message: string
|
||||
}>
|
||||
expect(Array.isArray(issues)).toBe(true)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
await fs.rmdir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("should include response body in RegistryFetchError", async () => {
|
||||
const errorResponse = {
|
||||
error: "Bad Request",
|
||||
details: "Invalid parameters",
|
||||
}
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york-v4/bad-request.json`, () => {
|
||||
return HttpResponse.json(errorResponse, { status: 400 })
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await getRegistryItems(["bad-request"])
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryFetchError)
|
||||
if (error instanceof RegistryFetchError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.FETCH_ERROR)
|
||||
expect(error.statusCode).toBe(400)
|
||||
expect(error.suggestion).toContain("client error")
|
||||
// Response body should be available as context
|
||||
expect(error.responseBody).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRegistry", () => {
|
||||
beforeEach(() => {
|
||||
clearRegistryCache()
|
||||
})
|
||||
|
||||
it("should fetch registry catalog", async () => {
|
||||
const registryData = {
|
||||
name: "@acme/registry",
|
||||
@@ -526,7 +575,7 @@ describe("getRegistry", () => {
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = await getRegistry("@acme/registry", mockConfig)
|
||||
const result = await getRegistry("@acme", mockConfig)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "@acme/registry",
|
||||
@@ -538,6 +587,37 @@ describe("getRegistry", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("should auto-append /registry to registry name", async () => {
|
||||
const registryData = {
|
||||
name: "@acme/registry",
|
||||
homepage: "https://acme.com",
|
||||
items: [],
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get("https://acme.com/registry.json", () => {
|
||||
return HttpResponse.json(registryData)
|
||||
})
|
||||
)
|
||||
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@acme": {
|
||||
url: "https://acme.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
// Test both with and without /registry suffix
|
||||
const result1 = await getRegistry("@acme", mockConfig)
|
||||
expect(result1.name).toBe("@acme/registry")
|
||||
|
||||
const result2 = await getRegistry("@acme/registry", mockConfig)
|
||||
expect(result2.name).toBe("@acme/registry")
|
||||
})
|
||||
|
||||
it("should handle registry with auth headers", async () => {
|
||||
const registryData = {
|
||||
name: "@private/registry",
|
||||
@@ -569,7 +649,7 @@ describe("getRegistry", () => {
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = await getRegistry("@private/registry", mockConfig)
|
||||
const result = await getRegistry("@private", mockConfig)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "@private/registry",
|
||||
@@ -580,10 +660,25 @@ describe("getRegistry", () => {
|
||||
expect(receivedHeaders.authorization).toBe("Bearer test-token")
|
||||
})
|
||||
|
||||
it("should return null on error", async () => {
|
||||
it("should throw RegistryNotConfiguredError when registry is not configured", async () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {},
|
||||
} as any
|
||||
|
||||
await expect(getRegistry("@unknown", mockConfig)).rejects.toThrow(
|
||||
RegistryNotConfiguredError
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw RegistryParseError on invalid registry data", async () => {
|
||||
server.use(
|
||||
http.get("https://example.com/registry.json", () => {
|
||||
return HttpResponse.json({ error: "Not found" }, { status: 404 })
|
||||
http.get("https://invalid.com/registry.json", () => {
|
||||
return HttpResponse.json({
|
||||
// Invalid registry data - missing required fields
|
||||
invalid: "data",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -591,13 +686,36 @@ describe("getRegistry", () => {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@example": {
|
||||
url: "https://example.com/{name}.json",
|
||||
"@invalid": {
|
||||
url: "https://invalid.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = await getRegistry("@example/registry", mockConfig)
|
||||
expect(result).toBe(null)
|
||||
await expect(getRegistry("@invalid", mockConfig)).rejects.toThrow(
|
||||
RegistryParseError
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw RegistryFetchError on network error", async () => {
|
||||
server.use(
|
||||
http.get("https://error.com/registry.json", () => {
|
||||
return HttpResponse.json({ error: "Server Error" }, { status: 500 })
|
||||
})
|
||||
)
|
||||
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@error": {
|
||||
url: "https://error.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
await expect(getRegistry("@error", mockConfig)).rejects.toThrow(
|
||||
RegistryFetchError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildHeadersFromRegistryConfig,
|
||||
buildUrlAndHeadersForRegistryItem,
|
||||
buildUrlFromRegistryConfig,
|
||||
resolveRegistryUrl,
|
||||
} from "./builder"
|
||||
|
||||
describe("buildUrlFromRegistryConfig", () => {
|
||||
@@ -447,3 +450,27 @@ describe("buildUrlAndHeadersForRegistryItem", () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRegistryUrl", () => {
|
||||
it("should return the URL as-is for valid URLs", () => {
|
||||
const url = "https://example.com/component.json"
|
||||
expect(resolveRegistryUrl(url)).toBe(url)
|
||||
})
|
||||
|
||||
it("should append /json to v0 registry URLs", () => {
|
||||
const v0Url = "https://v0.dev/chat/b/abc123"
|
||||
expect(resolveRegistryUrl(v0Url)).toBe("https://v0.dev/chat/b/abc123/json")
|
||||
})
|
||||
|
||||
it("should not append /json if already present", () => {
|
||||
const v0Url = "https://v0.dev/chat/b/abc123/json"
|
||||
expect(resolveRegistryUrl(v0Url)).toBe(v0Url)
|
||||
})
|
||||
|
||||
it("should prepend REGISTRY_URL for non-URLs", () => {
|
||||
expect(resolveRegistryUrl("test.json")).toBe(`${REGISTRY_URL}/test.json`)
|
||||
expect(resolveRegistryUrl("styles/default/button.json")).toBe(
|
||||
`${REGISTRY_URL}/styles/default/button.json`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { expandEnvVars } from "@/src/registry/env"
|
||||
import { RegistryNotConfiguredError } from "@/src/registry/errors"
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import { configSchema, registryConfigItemSchema } from "@/src/registry/schema"
|
||||
import { registryConfigItemSchema } from "@/src/registry/schema"
|
||||
import { isUrl } from "@/src/registry/utils"
|
||||
import { validateRegistryConfig } from "@/src/registry/validator"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { z } from "zod"
|
||||
|
||||
import { expandEnvVars } from "./env"
|
||||
|
||||
const NAME_PLACEHOLDER = "{name}"
|
||||
const STYLE_PLACEHOLDER = "{style}"
|
||||
const ENV_VAR_PATTERN = /\${(\w+)}/g
|
||||
@@ -13,7 +16,7 @@ const QUERY_PARAM_DELIMITER = "&"
|
||||
|
||||
export function buildUrlAndHeadersForRegistryItem(
|
||||
name: string,
|
||||
config?: Pick<z.infer<typeof configSchema>, "registries" | "style">
|
||||
config?: Config
|
||||
) {
|
||||
const { registry, item } = parseRegistryAndItemFromString(name)
|
||||
|
||||
@@ -24,10 +27,7 @@ export function buildUrlAndHeadersForRegistryItem(
|
||||
const registries = config?.registries || {}
|
||||
const registryConfig = registries[registry]
|
||||
if (!registryConfig) {
|
||||
throw new Error(
|
||||
`Unknown registry "${registry}". Make sure it is defined in components.json as follows:\n` +
|
||||
`{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}`
|
||||
)
|
||||
throw new RegistryNotConfiguredError(registry)
|
||||
}
|
||||
|
||||
// TODO: I don't like this here.
|
||||
@@ -43,7 +43,7 @@ export function buildUrlAndHeadersForRegistryItem(
|
||||
export function buildUrlFromRegistryConfig(
|
||||
item: string,
|
||||
registryConfig: z.infer<typeof registryConfigItemSchema>,
|
||||
config?: Pick<z.infer<typeof configSchema>, "style">
|
||||
config?: Config
|
||||
) {
|
||||
if (typeof registryConfig === "string") {
|
||||
let url = registryConfig.replace(NAME_PLACEHOLDER, item)
|
||||
@@ -129,3 +129,25 @@ function shouldIncludeHeader(originalValue: string, expandedValue: string) {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a registry URL from a path or URL string.
|
||||
* Handles special cases like v0 registry URLs that need /json suffix.
|
||||
*
|
||||
* @param pathOrUrl - Either a relative path or a full URL
|
||||
* @returns The resolved registry URL
|
||||
*/
|
||||
export function resolveRegistryUrl(pathOrUrl: string) {
|
||||
if (isUrl(pathOrUrl)) {
|
||||
// If the url contains /chat/b/, we assume it's the v0 registry.
|
||||
// We need to add the /json suffix if it's missing.
|
||||
const url = new URL(pathOrUrl)
|
||||
if (url.pathname.match(/\/chat\/b\//) && !url.pathname.endsWith("/json")) {
|
||||
url.pathname = `${url.pathname}/json`
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
return `${REGISTRY_URL}/${pathOrUrl}`
|
||||
}
|
||||
|
||||
221
packages/shadcn/src/registry/config.test.ts
Normal file
221
packages/shadcn/src/registry/config.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { BUILTIN_REGISTRIES, FALLBACK_STYLE } from "@/src/registry/constants"
|
||||
import { createConfig } from "@/src/utils/get-config"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { configWithDefaults } from "./config"
|
||||
|
||||
describe("configWithDefaults", () => {
|
||||
it("should merge built-in registries with user registries", () => {
|
||||
const userConfig = createConfig({
|
||||
registries: {
|
||||
"@custom": "http://example.com/{name}",
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(userConfig)
|
||||
|
||||
expect(result.registries).toEqual({
|
||||
...BUILTIN_REGISTRIES,
|
||||
"@custom": "http://example.com/{name}",
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve user registries when merging", () => {
|
||||
const userConfig = createConfig({
|
||||
registries: {
|
||||
"@one": "http://one.com/{name}",
|
||||
"@two": {
|
||||
url: "http://two.com/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(userConfig)
|
||||
|
||||
expect(result.registries?.["@one"]).toBe("http://one.com/{name}")
|
||||
expect(result.registries?.["@two"]).toEqual({
|
||||
url: "http://two.com/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
})
|
||||
expect(result.registries?.["@shadcn"]).toBe(BUILTIN_REGISTRIES["@shadcn"])
|
||||
})
|
||||
|
||||
it("should use FALLBACK_STYLE when style is new-york and tailwind.config is empty", () => {
|
||||
const config = createConfig({
|
||||
style: "new-york",
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "app/globals.css",
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
expect(result.style).toBe(FALLBACK_STYLE)
|
||||
})
|
||||
|
||||
it("should keep new-york style when tailwind.config is not empty", () => {
|
||||
const config = createConfig({
|
||||
style: "new-york",
|
||||
tailwind: {
|
||||
config: "tailwind.config.js",
|
||||
css: "app/globals.css",
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
expect(result.style).toBe("new-york")
|
||||
})
|
||||
|
||||
it("should preserve non-new-york styles regardless of tailwind config", () => {
|
||||
const config1 = createConfig({
|
||||
style: "default",
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "app/globals.css",
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result1 = configWithDefaults(config1)
|
||||
expect(result1.style).toBe("default")
|
||||
|
||||
const config2 = createConfig({
|
||||
style: "miami",
|
||||
tailwind: {
|
||||
config: "tailwind.config.js",
|
||||
css: "app/globals.css",
|
||||
baseColor: "slate",
|
||||
cssVariables: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result2 = configWithDefaults(config2)
|
||||
expect(result2.style).toBe("miami")
|
||||
})
|
||||
|
||||
it("should use FALLBACK_STYLE when no style is provided", () => {
|
||||
const config = createConfig({
|
||||
style: undefined,
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
expect(result.style).toBe(FALLBACK_STYLE)
|
||||
})
|
||||
|
||||
it("should deeply merge nested config properties", () => {
|
||||
const config = createConfig({
|
||||
tailwind: {
|
||||
css: "custom/path/globals.css",
|
||||
prefix: "tw-",
|
||||
baseColor: "zinc",
|
||||
cssVariables: false,
|
||||
},
|
||||
aliases: {
|
||||
components: "@/custom-components",
|
||||
utils: "@/custom-utils",
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
expect(result.tailwind.css).toBe("custom/path/globals.css")
|
||||
expect(result.tailwind.prefix).toBe("tw-")
|
||||
expect(result.tailwind.baseColor).toBe("zinc")
|
||||
expect(result.tailwind.cssVariables).toBe(false)
|
||||
expect(result.aliases.components).toBe("@/custom-components")
|
||||
expect(result.aliases.utils).toBe("@/custom-utils")
|
||||
})
|
||||
|
||||
it("should preserve all user config properties", () => {
|
||||
const config = createConfig({
|
||||
style: "default",
|
||||
tsx: false,
|
||||
rsc: false,
|
||||
tailwind: {
|
||||
config: "custom.config.js",
|
||||
css: "styles/main.css",
|
||||
baseColor: "gray",
|
||||
cssVariables: true,
|
||||
prefix: "app-",
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd: "/custom/project",
|
||||
tailwindConfig: "/custom/project/tailwind.config.js",
|
||||
tailwindCss: "/custom/project/styles/main.css",
|
||||
utils: "/custom/project/lib/utils",
|
||||
components: "/custom/project/components",
|
||||
ui: "/custom/project/components/ui",
|
||||
lib: "/custom/project/lib",
|
||||
hooks: "/custom/project/hooks",
|
||||
},
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
expect(result.tsx).toBe(false)
|
||||
expect(result.rsc).toBe(false)
|
||||
expect(result.tailwind.config).toBe("custom.config.js")
|
||||
expect(result.tailwind.css).toBe("styles/main.css")
|
||||
expect(result.tailwind.baseColor).toBe("gray")
|
||||
expect(result.tailwind.prefix).toBe("app-")
|
||||
expect(result.resolvedPaths.cwd).toBe("/custom/project")
|
||||
expect(result.resolvedPaths.components).toBe("/custom/project/components")
|
||||
})
|
||||
|
||||
it("should handle empty registries object", () => {
|
||||
const config = createConfig({
|
||||
registries: {},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
expect(result.registries).toEqual(BUILTIN_REGISTRIES)
|
||||
})
|
||||
|
||||
it("should override built-in registries if user provides same key", () => {
|
||||
const config = createConfig({
|
||||
registries: {
|
||||
"@shadcn": "http://custom-shadcn.com/{name}",
|
||||
},
|
||||
})
|
||||
|
||||
const result = configWithDefaults(config)
|
||||
|
||||
// User's @shadcn should override the built-in one
|
||||
expect(result.registries?.["@shadcn"]).toBe(
|
||||
"http://custom-shadcn.com/{name}"
|
||||
)
|
||||
})
|
||||
|
||||
it("should validate the final config with configSchema", () => {
|
||||
const config = createConfig({
|
||||
style: "default",
|
||||
registries: {
|
||||
"@test": "http://test.com/{name}",
|
||||
},
|
||||
})
|
||||
|
||||
// This should not throw since configSchema.parse is called internally
|
||||
expect(() => configWithDefaults(config)).not.toThrow()
|
||||
})
|
||||
})
|
||||
37
packages/shadcn/src/registry/config.ts
Normal file
37
packages/shadcn/src/registry/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BUILTIN_REGISTRIES, FALLBACK_STYLE } from "@/src/registry/constants"
|
||||
import { configSchema } from "@/src/registry/schema"
|
||||
import { Config, createConfig } from "@/src/utils/get-config"
|
||||
import deepmerge from "deepmerge"
|
||||
|
||||
function resolveStyleFromConfig(config: Partial<Config> | Config) {
|
||||
if (!config.style) {
|
||||
return FALLBACK_STYLE
|
||||
}
|
||||
|
||||
// Check if we should use new-york-v4 for Tailwind v4.
|
||||
// We assume that if tailwind.config is empty, we're using Tailwind v4.
|
||||
if (config.style === "new-york" && config.tailwind?.config === "") {
|
||||
return FALLBACK_STYLE
|
||||
}
|
||||
|
||||
return config.style
|
||||
}
|
||||
|
||||
export function configWithDefaults(config?: Partial<Config> | Config) {
|
||||
const baseConfig = createConfig({
|
||||
style: FALLBACK_STYLE,
|
||||
registries: BUILTIN_REGISTRIES,
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
return configSchema.parse(
|
||||
deepmerge(baseConfig, {
|
||||
...config,
|
||||
style: resolveStyleFromConfig(config),
|
||||
registries: { ...BUILTIN_REGISTRIES, ...config.registries },
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,34 @@
|
||||
import { registryConfigSchema } from "@/src/registry/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
import { registryConfigSchema } from "./schema"
|
||||
|
||||
export const REGISTRY_URL =
|
||||
process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
|
||||
|
||||
export const FALLBACK_STYLE = "new-york-v4"
|
||||
|
||||
export const BASE_COLORS = [
|
||||
{
|
||||
name: "neutral",
|
||||
label: "Neutral",
|
||||
},
|
||||
{
|
||||
name: "gray",
|
||||
label: "Gray",
|
||||
},
|
||||
{
|
||||
name: "zinc",
|
||||
label: "Zinc",
|
||||
},
|
||||
{
|
||||
name: "stone",
|
||||
label: "Stone",
|
||||
},
|
||||
{
|
||||
name: "slate",
|
||||
label: "Slate",
|
||||
},
|
||||
] as const
|
||||
|
||||
// Built-in registries that are always available and cannot be overridden
|
||||
export const BUILTIN_REGISTRIES: z.infer<typeof registryConfigSchema> = {
|
||||
"@shadcn": `${REGISTRY_URL}/styles/{style}/{name}.json`,
|
||||
@@ -133,3 +157,18 @@ export const BUILTIN_MODULES = new Set([
|
||||
"bun:internal",
|
||||
],
|
||||
])
|
||||
|
||||
export const DEPRECATED_COMPONENTS = [
|
||||
{
|
||||
name: "toast",
|
||||
deprecatedBy: "sonner",
|
||||
message:
|
||||
"The toast component is deprecated. Use the sonner component instead.",
|
||||
},
|
||||
{
|
||||
name: "toaster",
|
||||
deprecatedBy: "sonner",
|
||||
message:
|
||||
"The toaster component is deprecated. Use the sonner component instead.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -9,7 +9,8 @@ let context: RegistryContext = {
|
||||
export function setRegistryHeaders(
|
||||
headers: Record<string, Record<string, string>>
|
||||
) {
|
||||
context.headers = headers
|
||||
// Merge new headers with existing ones to preserve headers for nested dependencies
|
||||
context.headers = { ...context.headers, ...headers }
|
||||
}
|
||||
|
||||
export function getRegistryHeadersFromContext(
|
||||
|
||||
238
packages/shadcn/src/registry/errors.ts
Normal file
238
packages/shadcn/src/registry/errors.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// Error codes for programmatic error handling
|
||||
export const RegistryErrorCode = {
|
||||
// Network errors
|
||||
NETWORK_ERROR: "NETWORK_ERROR",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
FETCH_ERROR: "FETCH_ERROR",
|
||||
|
||||
// Configuration errors
|
||||
NOT_CONFIGURED: "NOT_CONFIGURED",
|
||||
INVALID_CONFIG: "INVALID_CONFIG",
|
||||
MISSING_ENV_VARS: "MISSING_ENV_VARS",
|
||||
|
||||
// File system errors
|
||||
LOCAL_FILE_ERROR: "LOCAL_FILE_ERROR",
|
||||
|
||||
// Parsing errors
|
||||
PARSE_ERROR: "PARSE_ERROR",
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
|
||||
// Generic errors
|
||||
UNKNOWN_ERROR: "UNKNOWN_ERROR",
|
||||
} as const
|
||||
|
||||
export type RegistryErrorCode =
|
||||
(typeof RegistryErrorCode)[keyof typeof RegistryErrorCode]
|
||||
|
||||
export class RegistryError extends Error {
|
||||
public readonly code: RegistryErrorCode
|
||||
public readonly statusCode?: number
|
||||
public readonly context?: Record<string, unknown>
|
||||
public readonly suggestion?: string
|
||||
public readonly timestamp: Date
|
||||
public readonly cause?: unknown
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
code?: RegistryErrorCode
|
||||
statusCode?: number
|
||||
cause?: unknown
|
||||
context?: Record<string, unknown>
|
||||
suggestion?: string
|
||||
} = {}
|
||||
) {
|
||||
super(message)
|
||||
this.name = "RegistryError"
|
||||
this.code = options.code || RegistryErrorCode.UNKNOWN_ERROR
|
||||
this.statusCode = options.statusCode
|
||||
this.cause = options.cause
|
||||
this.context = options.context
|
||||
this.suggestion = options.suggestion
|
||||
this.timestamp = new Date()
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
statusCode: this.statusCode,
|
||||
context: this.context,
|
||||
suggestion: this.suggestion,
|
||||
timestamp: this.timestamp,
|
||||
stack: this.stack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryNotFoundError extends RegistryError {
|
||||
constructor(public readonly url: string, cause?: unknown) {
|
||||
const message = `The item at ${url} was not found. It may not exist at the registry.`
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.NOT_FOUND,
|
||||
statusCode: 404,
|
||||
cause,
|
||||
context: { url },
|
||||
suggestion:
|
||||
"Check if the item name is correct and the registry URL is accessible.",
|
||||
})
|
||||
this.name = "RegistryNotFoundError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryUnauthorizedError extends RegistryError {
|
||||
constructor(public readonly url: string, cause?: unknown) {
|
||||
const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.`
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.UNAUTHORIZED,
|
||||
statusCode: 401,
|
||||
cause,
|
||||
context: { url },
|
||||
suggestion:
|
||||
"Check your authentication credentials and environment variables.",
|
||||
})
|
||||
this.name = "RegistryUnauthorizedError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryForbiddenError extends RegistryError {
|
||||
constructor(public readonly url: string, cause?: unknown) {
|
||||
const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.`
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.FORBIDDEN,
|
||||
statusCode: 403,
|
||||
cause,
|
||||
context: { url },
|
||||
suggestion:
|
||||
"Check your authentication credentials and environment variables.",
|
||||
})
|
||||
this.name = "RegistryForbiddenError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryFetchError extends RegistryError {
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
statusCode?: number,
|
||||
public readonly responseBody?: string,
|
||||
cause?: unknown
|
||||
) {
|
||||
// Use the error detail from the server if available
|
||||
const baseMessage = statusCode
|
||||
? `Failed to fetch from registry (${statusCode}): ${url}`
|
||||
: `Failed to fetch from registry: ${url}`
|
||||
|
||||
const message =
|
||||
typeof cause === "string" && cause
|
||||
? `${baseMessage} - ${cause}`
|
||||
: baseMessage
|
||||
|
||||
let suggestion = "Check your network connection and try again."
|
||||
if (statusCode === 404) {
|
||||
suggestion =
|
||||
"The requested resource was not found. Check the URL or item name."
|
||||
} else if (statusCode === 500) {
|
||||
suggestion = "The registry server encountered an error. Try again later."
|
||||
} else if (statusCode && statusCode >= 400 && statusCode < 500) {
|
||||
suggestion = "There was a client error. Check your request parameters."
|
||||
}
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.FETCH_ERROR,
|
||||
statusCode,
|
||||
cause,
|
||||
context: { url, responseBody },
|
||||
suggestion,
|
||||
})
|
||||
this.name = "RegistryFetchError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryNotConfiguredError extends RegistryError {
|
||||
constructor(public readonly registryName: string | null) {
|
||||
const message = registryName
|
||||
? `Unknown registry "${registryName}". Make sure it is defined in components.json as follows:
|
||||
{
|
||||
"registries": {
|
||||
"${registryName}": "[URL_TO_REGISTRY]"
|
||||
}
|
||||
}`
|
||||
: `Unknown registry. Make sure it is defined in components.json under "registries".`
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.NOT_CONFIGURED,
|
||||
context: { registryName },
|
||||
suggestion:
|
||||
"Add the registry configuration to your components.json file. Consult the registry documentation for the correct format.",
|
||||
})
|
||||
this.name = "RegistryNotConfiguredError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryLocalFileError extends RegistryError {
|
||||
constructor(public readonly filePath: string, cause?: unknown) {
|
||||
super(`Failed to read local registry file: ${filePath}`, {
|
||||
code: RegistryErrorCode.LOCAL_FILE_ERROR,
|
||||
cause,
|
||||
context: { filePath },
|
||||
suggestion: "Check if the file exists and you have read permissions.",
|
||||
})
|
||||
this.name = "RegistryLocalFileError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryParseError extends RegistryError {
|
||||
public readonly parseError: unknown
|
||||
|
||||
constructor(public readonly item: string, parseError: unknown) {
|
||||
let message = `Failed to parse registry item: ${item}`
|
||||
|
||||
if (parseError instanceof z.ZodError) {
|
||||
message = `Failed to parse registry item: ${item}\n${parseError.errors
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n")}`
|
||||
}
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.PARSE_ERROR,
|
||||
cause: parseError,
|
||||
context: { item },
|
||||
suggestion:
|
||||
"The registry item may be corrupted or have an invalid format. Please make sure it returns a valid JSON object. See https://ui.shadcn.com/schema/registry-item.json.",
|
||||
})
|
||||
|
||||
this.parseError = parseError
|
||||
this.name = "RegistryParseError"
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryMissingEnvironmentVariablesError extends RegistryError {
|
||||
constructor(
|
||||
public readonly registryName: string,
|
||||
public readonly missingVars: string[]
|
||||
) {
|
||||
const message =
|
||||
`Registry "${registryName}" requires the following environment variables:\n\n` +
|
||||
missingVars.map((v) => ` • ${v}`).join("\n")
|
||||
|
||||
super(message, {
|
||||
code: RegistryErrorCode.MISSING_ENV_VARS,
|
||||
context: { registryName, missingVars },
|
||||
suggestion:
|
||||
"Set the required environment variables to your .env or .env.local file.",
|
||||
})
|
||||
this.name = "RegistryMissingEnvironmentVariablesError"
|
||||
}
|
||||
}
|
||||
222
packages/shadcn/src/registry/fetcher.test.ts
Normal file
222
packages/shadcn/src/registry/fetcher.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import {
|
||||
RegistryFetchError,
|
||||
RegistryForbiddenError,
|
||||
RegistryNotFoundError,
|
||||
RegistryUnauthorizedError,
|
||||
} from "@/src/registry/errors"
|
||||
import { HttpResponse, http } from "msw"
|
||||
import { setupServer } from "msw/node"
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
import { clearRegistryCache, fetchRegistry } from "./fetcher"
|
||||
|
||||
const server = setupServer(
|
||||
http.get(`${REGISTRY_URL}/test.json`, () => {
|
||||
return HttpResponse.json({
|
||||
name: "test",
|
||||
type: "registry:ui",
|
||||
})
|
||||
}),
|
||||
http.get(`${REGISTRY_URL}/error.json`, () => {
|
||||
return HttpResponse.error()
|
||||
}),
|
||||
http.get(`${REGISTRY_URL}/not-found.json`, () => {
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
}),
|
||||
http.get(`${REGISTRY_URL}/unauthorized.json`, () => {
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
http.get(`${REGISTRY_URL}/forbidden.json`, () => {
|
||||
return new HttpResponse(null, { status: 403 })
|
||||
}),
|
||||
http.get("https://external.com/component.json", () => {
|
||||
return HttpResponse.json({
|
||||
name: "external",
|
||||
type: "registry:ui",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
clearRegistryCache()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe("fetchRegistry", () => {
|
||||
it("should fetch a single registry item", async () => {
|
||||
const result = await fetchRegistry(["test.json"])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "test",
|
||||
type: "registry:ui",
|
||||
})
|
||||
})
|
||||
|
||||
it("should fetch multiple registry items in parallel", async () => {
|
||||
const result = await fetchRegistry(["test.json", "test.json"])
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ name: "test" })
|
||||
expect(result[1]).toMatchObject({ name: "test" })
|
||||
})
|
||||
|
||||
it("should fetch from external URLs", async () => {
|
||||
const result = await fetchRegistry(["https://external.com/component.json"])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "external",
|
||||
type: "registry:ui",
|
||||
})
|
||||
})
|
||||
|
||||
it("should use cache when enabled", async () => {
|
||||
// First fetch - should hit the server
|
||||
const result1 = await fetchRegistry(["test.json"], { useCache: true })
|
||||
expect(result1[0]).toMatchObject({ name: "test" })
|
||||
|
||||
// Second fetch - should use cache
|
||||
const result2 = await fetchRegistry(["test.json"], { useCache: true })
|
||||
expect(result2[0]).toMatchObject({ name: "test" })
|
||||
})
|
||||
|
||||
it("should not use cache when disabled", async () => {
|
||||
// Mock the server to return different responses
|
||||
let callCount = 0
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/cache-test.json`, () => {
|
||||
callCount++
|
||||
return HttpResponse.json({
|
||||
name: `test-${callCount}`,
|
||||
type: "registry:ui",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const result1 = await fetchRegistry(["cache-test.json"], {
|
||||
useCache: false,
|
||||
})
|
||||
expect(result1[0]).toMatchObject({ name: "test-1" })
|
||||
|
||||
const result2 = await fetchRegistry(["cache-test.json"], {
|
||||
useCache: false,
|
||||
})
|
||||
expect(result2[0]).toMatchObject({ name: "test-2" })
|
||||
})
|
||||
|
||||
it("should handle 404 errors", async () => {
|
||||
await expect(fetchRegistry(["not-found.json"])).rejects.toThrow(
|
||||
RegistryNotFoundError
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle 401 errors", async () => {
|
||||
await expect(fetchRegistry(["unauthorized.json"])).rejects.toThrow(
|
||||
RegistryUnauthorizedError
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle 403 errors", async () => {
|
||||
await expect(fetchRegistry(["forbidden.json"])).rejects.toThrow(
|
||||
RegistryForbiddenError
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle network errors", async () => {
|
||||
await expect(fetchRegistry(["error.json"])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should fetch registry data", async () => {
|
||||
const paths = ["styles/new-york/button.json"]
|
||||
const result = await fetchRegistry(paths)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
})
|
||||
})
|
||||
|
||||
it("should use cache for subsequent requests", async () => {
|
||||
const paths = ["styles/new-york/button.json"]
|
||||
let fetchCount = 0
|
||||
|
||||
// Clear any existing cache before test
|
||||
clearRegistryCache()
|
||||
|
||||
// Define the handler with counter before making requests
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, async () => {
|
||||
// Add a small delay to simulate network latency
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
fetchCount++
|
||||
return HttpResponse.json({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
files: [
|
||||
{
|
||||
path: "registry/new-york/ui/button.tsx",
|
||||
content: "// button component content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// First request
|
||||
const result1 = await fetchRegistry(paths)
|
||||
expect(fetchCount).toBe(1)
|
||||
expect(result1).toHaveLength(1)
|
||||
expect(result1[0]).toMatchObject({ name: "button" })
|
||||
|
||||
// Second request - should use cache
|
||||
const result2 = await fetchRegistry(paths)
|
||||
expect(fetchCount).toBe(1) // Should still be 1
|
||||
expect(result2).toHaveLength(1)
|
||||
expect(result2[0]).toMatchObject({ name: "button" })
|
||||
|
||||
// Third request - double check cache
|
||||
const result3 = await fetchRegistry(paths)
|
||||
expect(fetchCount).toBe(1) // Should still be 1
|
||||
expect(result3).toHaveLength(1)
|
||||
expect(result3[0]).toMatchObject({ name: "button" })
|
||||
})
|
||||
|
||||
it("should handle multiple paths", async () => {
|
||||
const paths = ["styles/new-york/button.json", "styles/new-york/card.json"]
|
||||
const result = await fetchRegistry(paths)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ name: "button" })
|
||||
expect(result[1]).toMatchObject({ name: "card" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearRegistryCache", () => {
|
||||
it("should clear the cache", async () => {
|
||||
// First fetch - should hit the server
|
||||
const result1 = await fetchRegistry(["test.json"], { useCache: true })
|
||||
expect(result1[0]).toMatchObject({ name: "test" })
|
||||
|
||||
// Clear cache
|
||||
clearRegistryCache()
|
||||
|
||||
// Mock the server to return different response
|
||||
server.use(
|
||||
http.get(`${REGISTRY_URL}/test.json`, () => {
|
||||
return HttpResponse.json({
|
||||
name: "test-after-clear",
|
||||
type: "registry:ui",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Third fetch - should hit the server again after cache clear
|
||||
const result3 = await fetchRegistry(["test.json"], { useCache: true })
|
||||
expect(result3[0]).toMatchObject({ name: "test-after-clear" })
|
||||
})
|
||||
})
|
||||
156
packages/shadcn/src/registry/fetcher.ts
Normal file
156
packages/shadcn/src/registry/fetcher.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { homedir } from "os"
|
||||
import path from "path"
|
||||
import { resolveRegistryUrl } from "@/src/registry/builder"
|
||||
import { getRegistryHeadersFromContext } from "@/src/registry/context"
|
||||
import {
|
||||
RegistryFetchError,
|
||||
RegistryForbiddenError,
|
||||
RegistryLocalFileError,
|
||||
RegistryNotFoundError,
|
||||
RegistryParseError,
|
||||
RegistryUnauthorizedError,
|
||||
} from "@/src/registry/errors"
|
||||
import { registryItemSchema } from "@/src/registry/schema"
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import fetch from "node-fetch"
|
||||
import { z } from "zod"
|
||||
|
||||
const agent = process.env.https_proxy
|
||||
? new HttpsProxyAgent(process.env.https_proxy)
|
||||
: undefined
|
||||
|
||||
const registryCache = new Map<string, Promise<any>>()
|
||||
|
||||
export function clearRegistryCache() {
|
||||
registryCache.clear()
|
||||
}
|
||||
|
||||
export async function fetchRegistry(
|
||||
paths: string[],
|
||||
options: { useCache?: boolean } = {}
|
||||
) {
|
||||
options = {
|
||||
useCache: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const url = resolveRegistryUrl(path)
|
||||
|
||||
// Check cache first if caching is enabled
|
||||
if (options.useCache && registryCache.has(url)) {
|
||||
return registryCache.get(url)
|
||||
}
|
||||
|
||||
// Store the promise in the cache before awaiting if caching is enabled.
|
||||
const fetchPromise = (async () => {
|
||||
// Get headers from context for this URL.
|
||||
const headers = getRegistryHeadersFromContext(url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
agent,
|
||||
headers: {
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let messageFromServer = undefined
|
||||
|
||||
if (
|
||||
response.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
const json = await response.json()
|
||||
const parsed = z
|
||||
.object({
|
||||
// RFC 7807.
|
||||
detail: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
// Standard error response.
|
||||
message: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.safeParse(json)
|
||||
|
||||
if (parsed.success) {
|
||||
// Prefer RFC 7807 detail field, then message field.
|
||||
messageFromServer = parsed.data.detail || parsed.data.message
|
||||
|
||||
if (parsed.data.error) {
|
||||
messageFromServer = `[${parsed.data.error}] ${messageFromServer}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new RegistryUnauthorizedError(url, messageFromServer)
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new RegistryNotFoundError(url, messageFromServer)
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new RegistryForbiddenError(url, messageFromServer)
|
||||
}
|
||||
|
||||
throw new RegistryFetchError(
|
||||
url,
|
||||
response.status,
|
||||
messageFromServer
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
})()
|
||||
|
||||
if (options.useCache) {
|
||||
registryCache.set(url, fetchPromise)
|
||||
}
|
||||
return fetchPromise
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRegistryLocal(filePath: string) {
|
||||
try {
|
||||
// Handle tilde expansion for home directory
|
||||
let expandedPath = filePath
|
||||
if (filePath.startsWith("~/")) {
|
||||
expandedPath = path.join(homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(expandedPath)
|
||||
const content = await fs.readFile(resolvedPath, "utf8")
|
||||
const parsed = JSON.parse(content)
|
||||
|
||||
try {
|
||||
return registryItemSchema.parse(parsed)
|
||||
} catch (error) {
|
||||
throw new RegistryParseError(filePath, error)
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if this is a file not found error
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes("ENOENT") ||
|
||||
error.message.includes("no such file"))
|
||||
) {
|
||||
throw new RegistryLocalFileError(filePath, error)
|
||||
}
|
||||
// Re-throw parse errors as-is
|
||||
if (error instanceof RegistryParseError) {
|
||||
throw error
|
||||
}
|
||||
// For other errors (like JSON parse errors), throw as local file error
|
||||
throw new RegistryLocalFileError(filePath, error)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
// TODO: Move to a separate file to support client-side usage.
|
||||
export * from "./schema"
|
||||
|
||||
// TODO: Remove these once we have a proper api.
|
||||
export { resolveRegistryTree as internal_registryResolveItemsTree } from "./resolver"
|
||||
export { fetchRegistry } from "./fetcher"
|
||||
|
||||
export { getRegistryItems, resolveRegistryItems } from "./api"
|
||||
|
||||
export {
|
||||
registryResolveItemsTree as internal_registryResolveItemsTree,
|
||||
fetchRegistry,
|
||||
} from "./api"
|
||||
export { BUILTIN_REGISTRIES, REGISTRY_URL } from "./constants"
|
||||
export { buildUrlAndHeadersForRegistryItem } from "./builder"
|
||||
RegistryError,
|
||||
RegistryNotFoundError,
|
||||
RegistryUnauthorizedError,
|
||||
RegistryForbiddenError,
|
||||
RegistryFetchError,
|
||||
RegistryNotConfiguredError,
|
||||
RegistryLocalFileError,
|
||||
RegistryParseError,
|
||||
RegistryMissingEnvironmentVariablesError,
|
||||
} from "./errors"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,39 @@
|
||||
import { configSchema } from "@/src/registry/schema"
|
||||
import { createHash } from "crypto"
|
||||
import path from "path"
|
||||
import {
|
||||
getRegistryBaseColor,
|
||||
getShadcnRegistryIndex,
|
||||
} from "@/src/registry/api"
|
||||
import {
|
||||
buildUrlAndHeadersForRegistryItem,
|
||||
resolveRegistryUrl,
|
||||
} from "@/src/registry/builder"
|
||||
import { setRegistryHeaders } from "@/src/registry/context"
|
||||
import {
|
||||
RegistryNotConfiguredError,
|
||||
RegistryParseError,
|
||||
} from "@/src/registry/errors"
|
||||
import { fetchRegistry, fetchRegistryLocal } from "@/src/registry/fetcher"
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import {
|
||||
registryItemSchema,
|
||||
registryResolvedItemsTreeSchema,
|
||||
} from "@/src/registry/schema"
|
||||
import {
|
||||
deduplicateFilesByTarget,
|
||||
isLocalFile,
|
||||
isUrl,
|
||||
} from "@/src/registry/utils"
|
||||
import { Config, getTargetStyleFromConfig } from "@/src/utils/get-config"
|
||||
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { buildTailwindThemeColorsFromCssVars } from "@/src/utils/updaters/update-tailwind-config"
|
||||
import deepmerge from "deepmerge"
|
||||
import { z } from "zod"
|
||||
|
||||
import { buildUrlAndHeadersForRegistryItem } from "./builder"
|
||||
import { setRegistryHeaders } from "./context"
|
||||
|
||||
export function resolveRegistryItemsFromRegistries(
|
||||
items: string[],
|
||||
config?: Pick<z.infer<typeof configSchema>, "registries" | "style">
|
||||
config: Config
|
||||
) {
|
||||
const registryHeaders: Record<string, Record<string, string>> = {}
|
||||
const resolvedItems = [...items]
|
||||
@@ -32,3 +59,668 @@ export function resolveRegistryItemsFromRegistries(
|
||||
|
||||
return resolvedItems
|
||||
}
|
||||
|
||||
// Internal function that fetches registry items without clearing context.
|
||||
// This is used for recursive dependency resolution.
|
||||
export async function fetchRegistryItems(
|
||||
items: string[],
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
) {
|
||||
const results = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (isLocalFile(item)) {
|
||||
return fetchRegistryLocal(item)
|
||||
}
|
||||
|
||||
if (isUrl(item)) {
|
||||
const [result] = await fetchRegistry([item], options)
|
||||
try {
|
||||
return registryItemSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new RegistryParseError(item, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.startsWith("@") && config?.registries) {
|
||||
const paths = resolveRegistryItemsFromRegistries([item], config)
|
||||
const [result] = await fetchRegistry(paths, options)
|
||||
try {
|
||||
return registryItemSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new RegistryParseError(item, error)
|
||||
}
|
||||
}
|
||||
|
||||
const path = `styles/${config?.style ?? "new-york-v4"}/${item}.json`
|
||||
const [result] = await fetchRegistry([path], options)
|
||||
try {
|
||||
return registryItemSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new RegistryParseError(item, error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Helper schema for items with source tracking
|
||||
const registryItemWithSourceSchema = registryItemSchema.extend({
|
||||
_source: z.string().optional(),
|
||||
})
|
||||
|
||||
// Resolves a list of registry items with all their dependencies and returns
|
||||
// a complete installation bundle with merged configuration.
|
||||
export async function resolveRegistryTree(
|
||||
names: z.infer<typeof registryItemSchema>["name"][],
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
) {
|
||||
try {
|
||||
options = {
|
||||
useCache: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
let allDependencyItems: z.infer<typeof registryItemWithSourceSchema>[] = []
|
||||
let allDependencyRegistryNames: string[] = []
|
||||
|
||||
const uniqueNames = Array.from(new Set(names))
|
||||
|
||||
const results = await fetchRegistryItems(uniqueNames, config, options)
|
||||
|
||||
const resultMap = new Map<string, z.infer<typeof registryItemSchema>>()
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i]) {
|
||||
resultMap.set(uniqueNames[i], results[i])
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sourceName, item] of Array.from(resultMap.entries())) {
|
||||
// Add source tracking
|
||||
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
|
||||
...item,
|
||||
_source: sourceName,
|
||||
}
|
||||
payload.push(itemWithSource)
|
||||
|
||||
if (item.registryDependencies) {
|
||||
// Resolve namespace syntax and set headers for dependencies
|
||||
let resolvedDependencies = item.registryDependencies
|
||||
|
||||
// Check for namespaced dependencies when no registries are configured
|
||||
if (!config?.registries) {
|
||||
const namespacedDeps = item.registryDependencies.filter(
|
||||
(dep: string) => dep.startsWith("@")
|
||||
)
|
||||
if (namespacedDeps.length > 0) {
|
||||
const { registry } = parseRegistryAndItemFromString(
|
||||
namespacedDeps[0]
|
||||
)
|
||||
throw new RegistryNotConfiguredError(registry)
|
||||
}
|
||||
} else {
|
||||
resolvedDependencies = resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
const { items, registryNames } = await resolveDependenciesRecursively(
|
||||
resolvedDependencies,
|
||||
config,
|
||||
options,
|
||||
new Set(uniqueNames)
|
||||
)
|
||||
allDependencyItems.push(...items)
|
||||
allDependencyRegistryNames.push(...registryNames)
|
||||
}
|
||||
}
|
||||
|
||||
payload.push(...allDependencyItems)
|
||||
|
||||
// Handle any remaining registry names that need index resolution
|
||||
if (allDependencyRegistryNames.length > 0) {
|
||||
// Remove duplicates from registry names
|
||||
const uniqueRegistryNames = Array.from(
|
||||
new Set(allDependencyRegistryNames)
|
||||
)
|
||||
|
||||
// Separate namespaced and non-namespaced items
|
||||
const nonNamespacedItems = uniqueRegistryNames.filter(
|
||||
(name) => !name.startsWith("@")
|
||||
)
|
||||
const namespacedDepItems = uniqueRegistryNames.filter((name) =>
|
||||
name.startsWith("@")
|
||||
)
|
||||
|
||||
// Handle namespaced dependency items
|
||||
if (namespacedDepItems.length > 0) {
|
||||
// This will now throw specific errors on failure
|
||||
const depResults = await fetchRegistryItems(
|
||||
namespacedDepItems,
|
||||
config,
|
||||
options
|
||||
)
|
||||
|
||||
for (let i = 0; i < depResults.length; i++) {
|
||||
const item = depResults[i]
|
||||
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
|
||||
...item,
|
||||
_source: namespacedDepItems[i],
|
||||
}
|
||||
payload.push(itemWithSource)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-namespaced items, we need the index and style resolution
|
||||
if (nonNamespacedItems.length > 0) {
|
||||
const index = await getShadcnRegistryIndex()
|
||||
if (!index && payload.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (index) {
|
||||
// If we're resolving the index, we want it to go first
|
||||
if (nonNamespacedItems.includes("index")) {
|
||||
nonNamespacedItems.unshift("index")
|
||||
}
|
||||
|
||||
// Resolve non-namespaced items through the existing flow
|
||||
// Get URLs for all registry items including their dependencies
|
||||
const registryUrls: string[] = []
|
||||
for (const name of nonNamespacedItems) {
|
||||
const itemDependencies = await resolveRegistryDependencies(
|
||||
name,
|
||||
config,
|
||||
options
|
||||
)
|
||||
registryUrls.push(...itemDependencies)
|
||||
}
|
||||
|
||||
// Deduplicate URLs
|
||||
const uniqueUrls = Array.from(new Set(registryUrls))
|
||||
let result = await fetchRegistry(uniqueUrls, options)
|
||||
const registryPayload = z.array(registryItemSchema).parse(result)
|
||||
payload.push(...registryPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// No deduplication - we want to support multiple items with the same name from different sources
|
||||
|
||||
// If we're resolving the index, we want to fetch
|
||||
// the theme item if a base color is provided.
|
||||
// We do this for index only.
|
||||
// Other components will ship with their theme tokens.
|
||||
if (
|
||||
uniqueNames.includes("index") ||
|
||||
allDependencyRegistryNames.includes("index")
|
||||
) {
|
||||
if (config.tailwind.baseColor) {
|
||||
const theme = await registryGetTheme(config.tailwind.baseColor, config)
|
||||
if (theme) {
|
||||
payload.unshift(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build source map for topological sort
|
||||
const sourceMap = new Map<z.infer<typeof registryItemSchema>, string>()
|
||||
payload.forEach((item) => {
|
||||
// Use the _source property if it was added, otherwise use the name
|
||||
const source = item._source || item.name
|
||||
sourceMap.set(item, source)
|
||||
})
|
||||
|
||||
// Apply topological sort to ensure dependencies come before dependents
|
||||
payload = topologicalSortRegistryItems(payload, sourceMap)
|
||||
|
||||
// Sort the payload so that registry:theme items come first,
|
||||
// while maintaining the relative order of all items.
|
||||
payload.sort((a, b) => {
|
||||
if (a.type === "registry:theme" && b.type !== "registry:theme") {
|
||||
return -1
|
||||
}
|
||||
if (a.type !== "registry:theme" && b.type === "registry:theme") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
let tailwind = {}
|
||||
payload.forEach((item) => {
|
||||
tailwind = deepmerge(tailwind, item.tailwind ?? {})
|
||||
})
|
||||
|
||||
let cssVars = {}
|
||||
payload.forEach((item) => {
|
||||
cssVars = deepmerge(cssVars, item.cssVars ?? {})
|
||||
})
|
||||
|
||||
let css = {}
|
||||
payload.forEach((item) => {
|
||||
css = deepmerge(css, item.css ?? {})
|
||||
})
|
||||
|
||||
let docs = ""
|
||||
payload.forEach((item) => {
|
||||
if (item.docs) {
|
||||
docs += `${item.docs}\n`
|
||||
}
|
||||
})
|
||||
|
||||
let envVars = {}
|
||||
payload.forEach((item) => {
|
||||
envVars = deepmerge(envVars, item.envVars ?? {})
|
||||
})
|
||||
|
||||
// Deduplicate files based on resolved target paths.
|
||||
const deduplicatedFiles = await deduplicateFilesByTarget(
|
||||
payload.map((item) => item.files ?? []),
|
||||
config
|
||||
)
|
||||
|
||||
const parsed = registryResolvedItemsTreeSchema.parse({
|
||||
dependencies: deepmerge.all(
|
||||
payload.map((item) => item.dependencies ?? [])
|
||||
),
|
||||
devDependencies: deepmerge.all(
|
||||
payload.map((item) => item.devDependencies ?? [])
|
||||
),
|
||||
files: deduplicatedFiles,
|
||||
tailwind,
|
||||
cssVars,
|
||||
css,
|
||||
docs,
|
||||
})
|
||||
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
parsed.envVars = envVars
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDependenciesRecursively(
|
||||
dependencies: string[],
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {},
|
||||
visited: Set<string> = new Set()
|
||||
) {
|
||||
const items: z.infer<typeof registryItemSchema>[] = []
|
||||
const registryNames: string[] = []
|
||||
|
||||
for (const dep of dependencies) {
|
||||
if (visited.has(dep)) {
|
||||
continue
|
||||
}
|
||||
visited.add(dep)
|
||||
|
||||
// Handle URLs and local files directly.
|
||||
if (isUrl(dep) || isLocalFile(dep)) {
|
||||
const [item] = await fetchRegistryItems([dep], config, options)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
if (item.registryDependencies) {
|
||||
// Resolve namespaced dependencies to set proper headers.
|
||||
const resolvedDeps = config?.registries
|
||||
? resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
: item.registryDependencies
|
||||
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
resolvedDeps,
|
||||
config,
|
||||
options,
|
||||
visited
|
||||
)
|
||||
items.push(...nested.items)
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle namespaced items (e.g., @one/foo, @two/bar).
|
||||
else if (dep.startsWith("@") && config?.registries) {
|
||||
// Check if the registry exists.
|
||||
const { registry } = parseRegistryAndItemFromString(dep)
|
||||
if (registry && !(registry in config.registries)) {
|
||||
throw new RegistryNotConfiguredError(registry)
|
||||
}
|
||||
|
||||
// Let getRegistryItem handle the namespaced item with config
|
||||
// This ensures proper authentication headers are used
|
||||
const [item] = await fetchRegistryItems([dep], config, options)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
if (item.registryDependencies) {
|
||||
// Resolve namespaced dependencies to set proper headers.
|
||||
const resolvedDeps = config?.registries
|
||||
? resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
: item.registryDependencies
|
||||
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
resolvedDeps,
|
||||
config,
|
||||
options,
|
||||
visited
|
||||
)
|
||||
items.push(...nested.items)
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle regular component names.
|
||||
else {
|
||||
registryNames.push(dep)
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const [item] = await fetchRegistryItems([dep], config, options)
|
||||
if (item && item.registryDependencies) {
|
||||
// Resolve namespaced dependencies to set proper headers.
|
||||
const resolvedDeps = config?.registries
|
||||
? resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
config
|
||||
)
|
||||
: item.registryDependencies
|
||||
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
resolvedDeps,
|
||||
config,
|
||||
options,
|
||||
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 }
|
||||
}
|
||||
|
||||
async function resolveRegistryDependencies(
|
||||
url: string,
|
||||
config: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
) {
|
||||
if (isUrl(url)) {
|
||||
return [url]
|
||||
}
|
||||
|
||||
const { registryNames } = await resolveDependenciesRecursively(
|
||||
[url],
|
||||
config,
|
||||
options,
|
||||
new Set()
|
||||
)
|
||||
|
||||
const style = config.resolvedPaths?.cwd
|
||||
? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style)
|
||||
: config.style
|
||||
|
||||
const urls = registryNames.map((name) =>
|
||||
resolveRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`)
|
||||
)
|
||||
|
||||
return Array.from(new Set(urls))
|
||||
}
|
||||
|
||||
async function registryGetTheme(name: string, config: Config) {
|
||||
const [baseColor, tailwindVersion] = await Promise.all([
|
||||
getRegistryBaseColor(name),
|
||||
getProjectTailwindVersionFromConfig(config),
|
||||
])
|
||||
if (!baseColor) {
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO: Move this to the registry i.e registry:theme.
|
||||
const theme = {
|
||||
name,
|
||||
type: "registry:theme",
|
||||
tailwind: {
|
||||
config: {
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cssVars: {
|
||||
theme: {},
|
||||
light: {
|
||||
radius: "0.5rem",
|
||||
},
|
||||
dark: {},
|
||||
},
|
||||
} satisfies z.infer<typeof registryItemSchema>
|
||||
|
||||
if (config.tailwind.cssVariables) {
|
||||
theme.tailwind.config.theme.extend.colors = {
|
||||
...theme.tailwind.config.theme.extend.colors,
|
||||
...buildTailwindThemeColorsFromCssVars(baseColor.cssVars.dark ?? {}),
|
||||
}
|
||||
theme.cssVars = {
|
||||
theme: {
|
||||
...baseColor.cssVars.theme,
|
||||
...theme.cssVars.theme,
|
||||
},
|
||||
light: {
|
||||
...baseColor.cssVars.light,
|
||||
...theme.cssVars.light,
|
||||
},
|
||||
dark: {
|
||||
...baseColor.cssVars.dark,
|
||||
...theme.cssVars.dark,
|
||||
},
|
||||
}
|
||||
|
||||
if (tailwindVersion === "v4" && baseColor.cssVarsV4) {
|
||||
theme.cssVars = {
|
||||
theme: {
|
||||
...baseColor.cssVarsV4.theme,
|
||||
...theme.cssVars.theme,
|
||||
},
|
||||
light: {
|
||||
radius: "0.625rem",
|
||||
...baseColor.cssVarsV4.light,
|
||||
},
|
||||
dark: {
|
||||
...baseColor.cssVarsV4.dark,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
function computeItemHash(
|
||||
item: Pick<z.infer<typeof registryItemSchema>, "name">,
|
||||
source?: string
|
||||
) {
|
||||
const identifier = source || item.name
|
||||
|
||||
const hash = createHash("sha256")
|
||||
.update(identifier)
|
||||
.digest("hex")
|
||||
.substring(0, 8)
|
||||
|
||||
return `${item.name}::${hash}`
|
||||
}
|
||||
|
||||
function extractItemIdentifierFromDependency(dependency: string) {
|
||||
if (isUrl(dependency)) {
|
||||
const url = new URL(dependency)
|
||||
const pathname = url.pathname
|
||||
const match = pathname.match(/\/([^/]+)\.json$/)
|
||||
const name = match ? match[1] : path.basename(pathname, ".json")
|
||||
|
||||
return {
|
||||
name,
|
||||
hash: computeItemHash({ name }, dependency),
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalFile(dependency)) {
|
||||
const match = dependency.match(/\/([^/]+)\.json$/)
|
||||
const name = match ? match[1] : path.basename(dependency, ".json")
|
||||
|
||||
return {
|
||||
name,
|
||||
hash: computeItemHash({ name }, dependency),
|
||||
}
|
||||
}
|
||||
|
||||
const { item } = parseRegistryAndItemFromString(dependency)
|
||||
return {
|
||||
name: item,
|
||||
hash: computeItemHash({ name: item }, dependency),
|
||||
}
|
||||
}
|
||||
|
||||
function topologicalSortRegistryItems(
|
||||
items: z.infer<typeof registryItemSchema>[],
|
||||
sourceMap: Map<z.infer<typeof registryItemSchema>, string>
|
||||
) {
|
||||
const itemMap = new Map<string, z.infer<typeof registryItemSchema>>()
|
||||
const hashToItem = new Map<string, z.infer<typeof registryItemSchema>>()
|
||||
const inDegree = new Map<string, number>()
|
||||
const adjacencyList = new Map<string, string[]>()
|
||||
|
||||
items.forEach((item) => {
|
||||
const source = sourceMap.get(item) || item.name
|
||||
const hash = computeItemHash(item, source)
|
||||
|
||||
itemMap.set(hash, item)
|
||||
hashToItem.set(hash, item)
|
||||
inDegree.set(hash, 0)
|
||||
adjacencyList.set(hash, [])
|
||||
})
|
||||
|
||||
// Build a map of dependency to possible items.
|
||||
const depToHashes = new Map<string, string[]>()
|
||||
items.forEach((item) => {
|
||||
const source = sourceMap.get(item) || item.name
|
||||
const hash = computeItemHash(item, source)
|
||||
|
||||
if (!depToHashes.has(item.name)) {
|
||||
depToHashes.set(item.name, [])
|
||||
}
|
||||
depToHashes.get(item.name)!.push(hash)
|
||||
|
||||
if (source !== item.name) {
|
||||
if (!depToHashes.has(source)) {
|
||||
depToHashes.set(source, [])
|
||||
}
|
||||
depToHashes.get(source)!.push(hash)
|
||||
}
|
||||
})
|
||||
|
||||
items.forEach((item) => {
|
||||
const itemSource = sourceMap.get(item) || item.name
|
||||
const itemHash = computeItemHash(item, itemSource)
|
||||
|
||||
if (item.registryDependencies) {
|
||||
item.registryDependencies.forEach((dep) => {
|
||||
let depHash: string | undefined
|
||||
|
||||
const exactMatches = depToHashes.get(dep) || []
|
||||
if (exactMatches.length === 1) {
|
||||
depHash = exactMatches[0]
|
||||
} else if (exactMatches.length > 1) {
|
||||
// Multiple matches - try to disambiguate.
|
||||
// For now, just use the first one and warn.
|
||||
depHash = exactMatches[0]
|
||||
} else {
|
||||
const { name } = extractItemIdentifierFromDependency(dep)
|
||||
const nameMatches = depToHashes.get(name) || []
|
||||
if (nameMatches.length > 0) {
|
||||
depHash = nameMatches[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (depHash && itemMap.has(depHash)) {
|
||||
adjacencyList.get(depHash)!.push(itemHash)
|
||||
inDegree.set(itemHash, inDegree.get(itemHash)! + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Implements Kahn's algorithm.
|
||||
const queue: string[] = []
|
||||
const sorted: z.infer<typeof registryItemSchema>[] = []
|
||||
|
||||
inDegree.forEach((degree, hash) => {
|
||||
if (degree === 0) {
|
||||
queue.push(hash)
|
||||
}
|
||||
})
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentHash = queue.shift()!
|
||||
const item = itemMap.get(currentHash)!
|
||||
sorted.push(item)
|
||||
|
||||
adjacencyList.get(currentHash)!.forEach((dependentHash) => {
|
||||
const newDegree = inDegree.get(dependentHash)! - 1
|
||||
inDegree.set(dependentHash, newDegree)
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(dependentHash)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (sorted.length !== items.length) {
|
||||
console.warn("Circular dependency detected in registry items")
|
||||
// Return all items even if there are circular dependencies
|
||||
// Items not in sorted are part of circular dependencies
|
||||
const sortedHashes = new Set(
|
||||
sorted.map((item) => {
|
||||
const source = sourceMap.get(item) || item.name
|
||||
return computeItemHash(item, source)
|
||||
})
|
||||
)
|
||||
|
||||
items.forEach((item) => {
|
||||
const source = sourceMap.get(item) || item.name
|
||||
const hash = computeItemHash(item, source)
|
||||
if (!sortedHashes.has(hash)) {
|
||||
sorted.push(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
@@ -607,3 +607,18 @@ describe("canDeduplicateFiles", () => {
|
||||
expect(canDeduplicateFiles(undefined as any)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isUrl", () => {
|
||||
it("should return true for valid URLs", () => {
|
||||
expect(isUrl("https://example.com")).toBe(true)
|
||||
expect(isUrl("http://localhost:3000")).toBe(true)
|
||||
expect(isUrl("https://example.com/path/to/file.json")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-URLs", () => {
|
||||
expect(isUrl("not-a-url")).toBe(false)
|
||||
expect(isUrl("/path/to/file")).toBe(false)
|
||||
expect(isUrl("./relative/path")).toBe(false)
|
||||
expect(isUrl("~/home/path")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as fs from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import * as path from "path"
|
||||
import { configSchema, registryItemSchema } from "@/src/registry"
|
||||
import { registryItemFileSchema } from "@/src/registry/schema"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { resolveImport } from "@/src/utils/resolve-import"
|
||||
@@ -13,8 +14,6 @@ import { Project, ScriptKind } from "ts-morph"
|
||||
import { loadConfig } from "tsconfig-paths"
|
||||
import { z } from "zod"
|
||||
|
||||
import { registryItemFileSchema } from "./schema"
|
||||
|
||||
const FILE_EXTENSIONS_FOR_LOOKUP = [".tsx", ".ts", ".jsx", ".js", ".css"]
|
||||
const FILE_PATH_SKIP_LIST = ["lib/utils.ts"]
|
||||
const DEPENDENCY_SKIP_LIST = [
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("validateRegistryConfig", () => {
|
||||
it("should throw when env vars are missing", () => {
|
||||
expect(() => {
|
||||
validateRegistryConfig("@test", "https://api.com?token=${MISSING}")
|
||||
}).toThrow(/Registry "@test" requires environment variables/)
|
||||
}).toThrow(/Registry "@test" requires the following environment variables/)
|
||||
})
|
||||
|
||||
it("should list all missing variables", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { extractEnvVars } from "@/src/registry/env"
|
||||
import { RegistryMissingEnvironmentVariablesError } from "@/src/registry/errors"
|
||||
import { registryConfigItemSchema } from "@/src/registry/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
import { extractEnvVars } from "./env"
|
||||
import { registryConfigItemSchema } from "./schema"
|
||||
|
||||
export function extractEnvVarsFromRegistryConfig(
|
||||
config: z.infer<typeof registryConfigItemSchema>
|
||||
): string[] {
|
||||
@@ -37,20 +37,6 @@ export function validateRegistryConfig(
|
||||
const missing = requiredVars.filter((v) => !process.env[v])
|
||||
|
||||
if (missing.length > 0) {
|
||||
const suggestions = missing.map((v) => {
|
||||
// Common patterns for environment variable names
|
||||
if (v.includes("TOKEN")) return `export ${v}="your-token-here"`
|
||||
if (v.includes("KEY")) return `export ${v}="your-api-key-here"`
|
||||
if (v.includes("SECRET")) return `export ${v}="your-secret-here"`
|
||||
return `export ${v}="your-value-here"`
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
`Registry "${registryName}" requires environment variables:\n\n` +
|
||||
missing.map((v) => ` • ${v}`).join("\n") +
|
||||
"\n\nSet them in your environment:\n\n" +
|
||||
suggestions.map((s) => ` ${s}`).join("\n") +
|
||||
"\n\nOr add them to a .env file in your project root."
|
||||
)
|
||||
throw new RegistryMissingEnvironmentVariablesError(registryName, missing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "path"
|
||||
import { getRegistryItem, registryResolveItemsTree } from "@/src/registry/api"
|
||||
import { getRegistryItems } from "@/src/registry/api"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { resolveRegistryTree } from "@/src/registry/resolver"
|
||||
import {
|
||||
configSchema,
|
||||
registryItemFileSchema,
|
||||
@@ -77,7 +79,7 @@ async function addProjectComponents(
|
||||
const registrySpinner = spinner(`Checking registry.`, {
|
||||
silent: options.silent,
|
||||
})?.start()
|
||||
const tree = await registryResolveItemsTree(components, config)
|
||||
const tree = await resolveRegistryTree(components, configWithDefaults(config))
|
||||
|
||||
if (!tree) {
|
||||
registrySpinner?.fail()
|
||||
@@ -151,7 +153,7 @@ async function addWorkspaceComponents(
|
||||
const registrySpinner = spinner(`Checking registry.`, {
|
||||
silent: options.silent,
|
||||
})?.start()
|
||||
const tree = await registryResolveItemsTree(components, config)
|
||||
const tree = await resolveRegistryTree(components, configWithDefaults(config))
|
||||
|
||||
if (!tree) {
|
||||
registrySpinner?.fail()
|
||||
@@ -357,9 +359,7 @@ async function shouldOverwriteCssVars(
|
||||
components: z.infer<typeof registryItemSchema>["name"][],
|
||||
config: z.infer<typeof configSchema>
|
||||
) {
|
||||
let result = await Promise.all(
|
||||
components.map((component) => getRegistryItem(component, config))
|
||||
)
|
||||
const result = await getRegistryItems(components, config)
|
||||
const payload = z.array(registryItemSchema).parse(result)
|
||||
|
||||
return payload.some(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchRegistry } from "@/src/registry/api"
|
||||
import { fetchRegistry } from "@/src/registry/fetcher"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { execa } from "execa"
|
||||
import fs from "fs-extra"
|
||||
@@ -19,7 +19,7 @@ import { TEMPLATES, createProject } from "./create-project"
|
||||
vi.mock("fs-extra")
|
||||
vi.mock("execa")
|
||||
vi.mock("prompts")
|
||||
vi.mock("@/src/registry/api")
|
||||
vi.mock("@/src/registry/fetcher")
|
||||
vi.mock("@/src/utils/get-package-manager", () => ({
|
||||
getPackageManager: vi.fn().mockResolvedValue("npm"),
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { initOptionsSchema } from "@/src/commands/init"
|
||||
import { fetchRegistry } from "@/src/registry/api"
|
||||
import { fetchRegistry } from "@/src/registry/fetcher"
|
||||
import { getPackageManager } from "@/src/utils/get-package-manager"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RegistryError } from "@/src/registry/errors"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { z } from "zod"
|
||||
@@ -15,6 +16,25 @@ export function handleError(error: unknown) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (error instanceof RegistryError) {
|
||||
if (error.message) {
|
||||
logger.error(error.cause ? "Error:" : "Message:")
|
||||
logger.error(error.message)
|
||||
}
|
||||
|
||||
if (error.cause) {
|
||||
logger.error("\nMessage:")
|
||||
logger.error(error.cause)
|
||||
}
|
||||
|
||||
if (error.suggestion) {
|
||||
logger.error("\nSuggestion:")
|
||||
logger.error(error.suggestion)
|
||||
}
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error("Validation failed:")
|
||||
for (const [key, value] of Object.entries(error.flatten().fieldErrors)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { getRegistryItem } from "@/src/registry/api"
|
||||
import { getRegistryItems } from "@/src/registry/api"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
|
||||
export async function updateAppIndex(component: string, config: Config) {
|
||||
@@ -10,7 +10,7 @@ export async function updateAppIndex(component: string, config: Config) {
|
||||
return
|
||||
}
|
||||
|
||||
const registryItem = await getRegistryItem(component, config)
|
||||
const [registryItem] = await getRegistryItems([component], config)
|
||||
if (
|
||||
!registryItem?.meta?.importSpecifier ||
|
||||
!registryItem?.meta?.moduleSpecifier
|
||||
|
||||
@@ -1,559 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`registryResolveItemTree > should resolve index 1`] = `
|
||||
{
|
||||
"css": {},
|
||||
"cssVars": {
|
||||
"dark": {},
|
||||
"light": {
|
||||
"radius": "0.5rem",
|
||||
},
|
||||
"theme": {},
|
||||
},
|
||||
"dependencies": [
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
"@radix-ui/react-label",
|
||||
"tailwindcss-animate",
|
||||
"class-variance-authority",
|
||||
"lucide-react",
|
||||
],
|
||||
"devDependencies": [],
|
||||
"docs": "",
|
||||
"files": [
|
||||
{
|
||||
"content": "import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
",
|
||||
"path": "lib/utils.ts",
|
||||
"target": "",
|
||||
"type": "registry:lib",
|
||||
},
|
||||
{
|
||||
"content": ""use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
",
|
||||
"path": "ui/label.tsx",
|
||||
"target": "",
|
||||
"type": "registry:ui",
|
||||
},
|
||||
],
|
||||
"tailwind": {
|
||||
"config": {
|
||||
"plugins": [
|
||||
"require("tailwindcss-animate")",
|
||||
],
|
||||
"theme": {
|
||||
"extend": {
|
||||
"borderRadius": {
|
||||
"lg": "var(--radius)",
|
||||
"md": "calc(var(--radius) - 2px)",
|
||||
"sm": "calc(var(--radius) - 4px)",
|
||||
},
|
||||
"colors": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`registryResolveItemTree > should resolve items tree 1`] = `
|
||||
{
|
||||
"css": {},
|
||||
"cssVars": {},
|
||||
"dependencies": [
|
||||
"@radix-ui/react-slot",
|
||||
],
|
||||
"devDependencies": [],
|
||||
"docs": "",
|
||||
"files": [
|
||||
{
|
||||
"content": "import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
",
|
||||
"path": "ui/button.tsx",
|
||||
"target": "",
|
||||
"type": "registry:ui",
|
||||
},
|
||||
],
|
||||
"tailwind": {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`registryResolveItemTree > should resolve multiple items tree 1`] = `
|
||||
{
|
||||
"css": {},
|
||||
"cssVars": {},
|
||||
"dependencies": [
|
||||
"@radix-ui/react-slot",
|
||||
"@radix-ui/react-dialog",
|
||||
"cmdk",
|
||||
],
|
||||
"devDependencies": [],
|
||||
"docs": "",
|
||||
"files": [
|
||||
{
|
||||
"content": "import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
",
|
||||
"path": "ui/button.tsx",
|
||||
"target": "",
|
||||
"type": "registry:ui",
|
||||
},
|
||||
{
|
||||
"content": "import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
",
|
||||
"path": "ui/input.tsx",
|
||||
"target": "",
|
||||
"type": "registry:ui",
|
||||
},
|
||||
{
|
||||
"content": ""use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
",
|
||||
"path": "ui/dialog.tsx",
|
||||
"target": "",
|
||||
"type": "registry:ui",
|
||||
},
|
||||
{
|
||||
"content": ""use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/registry/default/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
",
|
||||
"path": "ui/command.tsx",
|
||||
"target": "",
|
||||
"type": "registry:ui",
|
||||
},
|
||||
],
|
||||
"tailwind": {},
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,13 +97,7 @@ describe("shadcn init - next-app", () => {
|
||||
describe("shadcn init - vite-app", () => {
|
||||
it("should init with custom alias and src", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("vite-app")
|
||||
await npxShadcn(
|
||||
fixturePath,
|
||||
["init", "--base-color=gray", "alert-dialog"],
|
||||
{
|
||||
debug: true,
|
||||
}
|
||||
)
|
||||
await npxShadcn(fixturePath, ["init", "--base-color=gray", "alert-dialog"])
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
|
||||
@@ -392,7 +392,7 @@ describe("registries", () => {
|
||||
expect(output.stdout).toContain('Unknown registry "@non-existent"')
|
||||
expect(output.stdout).toContain(
|
||||
'"registries": {\n' +
|
||||
' "@non-existent": "https://example.com/{name}.json"\n' +
|
||||
' "@non-existent": "[URL_TO_REGISTRY]"\n' +
|
||||
" }\n"
|
||||
)
|
||||
})
|
||||
@@ -445,9 +445,7 @@ describe("registries", () => {
|
||||
const output = await npxShadcn(fixturePath, ["add", "@acme/component"])
|
||||
expect(output.stdout).toContain('Unknown registry "@acme"')
|
||||
expect(output.stdout).toContain(
|
||||
'"registries": {\n' +
|
||||
' "@acme": "https://example.com/{name}.json"\n' +
|
||||
" }\n"
|
||||
'"registries": {\n' + ' "@acme": "[URL_TO_REGISTRY]"\n' + " }\n"
|
||||
)
|
||||
})
|
||||
|
||||
@@ -548,7 +546,7 @@ describe("registries", () => {
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["add", "@two/two"])
|
||||
expect(output.stdout).toContain("unknown registry @one")
|
||||
expect(output.stdout).toContain('Unknown registry "@one"')
|
||||
})
|
||||
|
||||
it("should show an error when adding multiple namespaced items with unconfigured registry", async () => {
|
||||
|
||||
Reference in New Issue
Block a user