mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-24 05:05:44 +00:00
feat(shadcn): add namespaced registries support (#7919)
* chore(shadcn): implement registries poc * feat(shadcn): refactor our initial implementation * feat(shadcn): properly resolve namespaced registryDependencies * feat(shadcn): resolve namespaced registries recursively * fix * feat(shadcn): implement dotenv support * test(shadcn): mock shadcn registry * fix * fix * fix * refactor(shadcn): update functions and tests * refactor(shadcn): add fetchFromRegistry (#7937) * fix * feat(shadcn): add shadcn as a built-in registry * fix * feat(shadcn): update no framework and shadcn
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"build:registry": "pnpm --filter=www,v4 build:registry && pnpm --filter=www,v4 lint:fix && pnpm format:write -- --loglevel silent",
|
||||
"registry:build": "pnpm --filter=v4 registry:build && pnpm lint:fix && pnpm format:write -- --loglevel silent",
|
||||
"registry:capture": "pnpm --filter=www registry:capture",
|
||||
"dev": "turbo run dev --parallel",
|
||||
"dev": "turbo run dev --filter=!www --parallel",
|
||||
"shadcn-ui:dev": "turbo --filter=shadcn-ui dev",
|
||||
"shadcn-ui": "pnpm --filter=shadcn-ui start:dev",
|
||||
"shadcn-ui:test": "pnpm --filter=shadcn-ui test",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@babel/core": "^7.22.1",
|
||||
"@babel/parser": "^7.22.6",
|
||||
"@babel/plugin-transform-typescript": "^7.22.5",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"commander": "^10.0.0",
|
||||
"cosmiconfig": "^8.1.3",
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import fs from "fs"
|
||||
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 { registryItemTypeSchema } from "@/src/registry/schema"
|
||||
import {
|
||||
isLocalFile,
|
||||
isUniversalRegistryItem,
|
||||
isUrl,
|
||||
} from "@/src/registry/utils"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { isUniversalRegistryItem } from "@/src/registry/utils"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { createProject } from "@/src/utils/create-project"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
@@ -82,37 +78,47 @@ export const add = new Command()
|
||||
...opts,
|
||||
})
|
||||
|
||||
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
|
||||
let registryItem: any = null
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
if (
|
||||
components.length > 0 &&
|
||||
(isUrl(components[0]) || isLocalFile(components[0]))
|
||||
) {
|
||||
registryItem = await getRegistryItem(components[0], "")
|
||||
itemType = registryItem?.type
|
||||
let initialConfig = await getConfig(options.cwd)
|
||||
if (!initialConfig) {
|
||||
initialConfig = createConfig({
|
||||
resolvedPaths: {
|
||||
cwd: options.cwd,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!options.yes &&
|
||||
(itemType === "registry:style" || itemType === "registry:theme")
|
||||
) {
|
||||
logger.break()
|
||||
const { confirm } = await prompts({
|
||||
type: "confirm",
|
||||
name: "confirm",
|
||||
message: highlighter.warn(
|
||||
`You are about to install a new ${itemType.replace(
|
||||
"registry:",
|
||||
""
|
||||
)}. \nExisting CSS variables and components will be overwritten. Continue?`
|
||||
),
|
||||
})
|
||||
if (!confirm) {
|
||||
if (components.length > 0) {
|
||||
const registryItem = await getRegistryItem(components[0], initialConfig)
|
||||
const itemType = registryItem?.type
|
||||
|
||||
if (isUniversalRegistryItem(registryItem)) {
|
||||
await addComponents(components, initialConfig, options)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!options.yes &&
|
||||
(itemType === "registry:style" || itemType === "registry:theme")
|
||||
) {
|
||||
logger.break()
|
||||
logger.log(`Installation cancelled.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
const { confirm } = await prompts({
|
||||
type: "confirm",
|
||||
name: "confirm",
|
||||
message: highlighter.warn(
|
||||
`You are about to install a new ${itemType.replace(
|
||||
"registry:",
|
||||
""
|
||||
)}. \nExisting CSS variables and components will be overwritten. Continue?`
|
||||
),
|
||||
})
|
||||
if (!confirm) {
|
||||
logger.break()
|
||||
logger.log(`Installation cancelled.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,22 +142,6 @@ export const add = new Command()
|
||||
}
|
||||
}
|
||||
|
||||
if (isUniversalRegistryItem(registryItem)) {
|
||||
// Universal items only cares about the cwd.
|
||||
if (!fs.existsSync(options.cwd)) {
|
||||
throw new Error(`Directory ${options.cwd} does not exist`)
|
||||
}
|
||||
|
||||
const minimalConfig = createConfig({
|
||||
resolvedPaths: {
|
||||
cwd: options.cwd,
|
||||
},
|
||||
})
|
||||
|
||||
await addComponents(options.components, minimalConfig, options)
|
||||
return
|
||||
}
|
||||
|
||||
let { errors, config } = await preFlightAdd(options)
|
||||
|
||||
// No components.json file. Prompt the user to run init.
|
||||
@@ -237,6 +227,8 @@ export const add = new Command()
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
|
||||
@@ -12,9 +13,13 @@ export const info = new Command()
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (opts) => {
|
||||
logger.info("> project info")
|
||||
console.log(await getProjectInfo(opts.cwd))
|
||||
logger.break()
|
||||
logger.info("> components.json")
|
||||
console.log(await getConfig(opts.cwd))
|
||||
try {
|
||||
logger.info("> project info")
|
||||
console.log(await getProjectInfo(opts.cwd))
|
||||
logger.break()
|
||||
logger.info("> components.json")
|
||||
console.log(await getConfig(opts.cwd))
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
getRegistryItem,
|
||||
getRegistryStyles,
|
||||
} from "@/src/registry/api"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { rawConfigSchema } from "@/src/registry/schema"
|
||||
import { isLocalFile, isUrl } from "@/src/registry/utils"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { TEMPLATES, createProject } from "@/src/utils/create-project"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import {
|
||||
DEFAULT_COMPONENTS,
|
||||
@@ -17,7 +20,6 @@ import {
|
||||
DEFAULT_TAILWIND_CSS,
|
||||
DEFAULT_UTILS,
|
||||
getConfig,
|
||||
rawConfigSchema,
|
||||
resolveConfigPaths,
|
||||
type Config,
|
||||
} from "@/src/utils/get-config"
|
||||
@@ -122,14 +124,15 @@ export const init = new Command()
|
||||
...opts,
|
||||
})
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
const config = await getConfig(options.cwd)
|
||||
|
||||
// We need to check if we're initializing with a new style.
|
||||
// We fetch the payload of the first item.
|
||||
// This is okay since the request is cached and deduped.
|
||||
if (
|
||||
components.length > 0 &&
|
||||
(isUrl(components[0]) || isLocalFile(components[0]))
|
||||
) {
|
||||
const item = await getRegistryItem(components[0], "")
|
||||
if (components.length > 0) {
|
||||
const item = await getRegistryItem(components[0], config || undefined)
|
||||
|
||||
// Skip base color if style.
|
||||
// We set a default and let the style override it.
|
||||
@@ -150,6 +153,8 @@ export const init = new Command()
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -408,7 +413,7 @@ async function promptForMinimalConfig(
|
||||
},
|
||||
rsc: defaultConfig?.rsc,
|
||||
tsx: defaultConfig?.tsx,
|
||||
aliases: defaultConfig?.aliases,
|
||||
iconLibrary: defaultConfig?.iconLibrary,
|
||||
aliases: defaultConfig?.aliases,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { preFlightRegistryBuild } from "@/src/preflights/preflight-registry"
|
||||
import { registryItemSchema, registrySchema } from "@/src/registry"
|
||||
import {
|
||||
configSchema,
|
||||
registryItemSchema,
|
||||
registrySchema,
|
||||
} from "@/src/registry"
|
||||
import { recursivelyResolveFileImports } from "@/src/registry/utils"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { configSchema } from "@/src/utils/get-config"
|
||||
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
|
||||
@@ -198,7 +198,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
|
||||
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
|
||||
const item = await getRegistryItem(itemUrl, "")
|
||||
const item = await getRegistryItem(itemUrl)
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(item, null, 2) }],
|
||||
@@ -213,7 +213,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
|
||||
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
|
||||
const item = await getRegistryItem(itemUrl, "")
|
||||
const item = await getRegistryItem(itemUrl)
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import {
|
||||
clearRegistryCache,
|
||||
fetchRegistry,
|
||||
getRegistry,
|
||||
getRegistryItem,
|
||||
registryResolveItemsTree,
|
||||
} from "./api"
|
||||
@@ -34,7 +36,7 @@ vi.mock("@/src/utils/logger", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const REGISTRY_URL = "https://ui.shadcn.com/r"
|
||||
const REGISTRY_URL = process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
|
||||
|
||||
const server = setupServer(
|
||||
http.get(`${REGISTRY_URL}/index.json`, () => {
|
||||
@@ -177,7 +179,7 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile, "unused-style")
|
||||
const result = await getRegistryItem(tempFile)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "test-component",
|
||||
@@ -215,10 +217,7 @@ describe("getRegistryItem with local files", () => {
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(tempDir)
|
||||
|
||||
const result = await getRegistryItem(
|
||||
"./relative-component.json",
|
||||
"unused-style"
|
||||
)
|
||||
const result = await getRegistryItem("./relative-component.json")
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "relative-component",
|
||||
@@ -249,7 +248,7 @@ describe("getRegistryItem with local files", () => {
|
||||
try {
|
||||
// Test with tilde path
|
||||
const tildeePath = "~/shadcn-test-tilde.json"
|
||||
const result = await getRegistryItem(tildeePath, "unused-style")
|
||||
const result = await getRegistryItem(tildeePath)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "tilde-component",
|
||||
@@ -262,10 +261,7 @@ describe("getRegistryItem with local files", () => {
|
||||
})
|
||||
|
||||
it("should return null for non-existent files", async () => {
|
||||
const result = await getRegistryItem(
|
||||
"/non/existent/file.json",
|
||||
"unused-style"
|
||||
)
|
||||
const result = await getRegistryItem("/non/existent/file.json")
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
@@ -276,7 +272,7 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, "{ invalid json }")
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile, "unused-style")
|
||||
const result = await getRegistryItem(tempFile)
|
||||
expect(result).toBe(null)
|
||||
} finally {
|
||||
// Clean up
|
||||
@@ -297,7 +293,7 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, JSON.stringify(invalidData))
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile, "unused-style")
|
||||
const result = await getRegistryItem(tempFile)
|
||||
expect(result).toBe(null)
|
||||
} finally {
|
||||
// Clean up
|
||||
@@ -308,7 +304,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", "new-york")
|
||||
const result = await getRegistryItem("button")
|
||||
expect(result).toMatchObject({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
@@ -353,7 +349,7 @@ describe("getRegistryItem with local files", () => {
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
|
||||
try {
|
||||
const result = await getRegistryItem(tempFile, "unused-style")
|
||||
const result = await getRegistryItem(tempFile)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "component-with-url-deps",
|
||||
@@ -427,4 +423,181 @@ describe("registryResolveItemsTree with URL dependencies", () => {
|
||||
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"
|
||||
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",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
|
||||
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
|
||||
|
||||
const result = await registryResolveItemsTree([tempFile], mockConfig)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.files).toBeDefined()
|
||||
|
||||
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)
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
await fs.rmdir(tempDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRegistry", () => {
|
||||
beforeEach(() => {
|
||||
clearRegistryCache()
|
||||
})
|
||||
|
||||
it("should fetch registry catalog", async () => {
|
||||
const registryData = {
|
||||
name: "@acme/registry",
|
||||
homepage: "https://acme.com",
|
||||
items: [
|
||||
{ name: "button", type: "registry:ui" },
|
||||
{ name: "card", type: "registry:ui" },
|
||||
],
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const result = await getRegistry("@acme/registry", mockConfig)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "@acme/registry",
|
||||
homepage: "https://acme.com",
|
||||
items: [
|
||||
{ name: "button", type: "registry:ui" },
|
||||
{ name: "card", type: "registry:ui" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle registry with auth headers", async () => {
|
||||
const registryData = {
|
||||
name: "@private/registry",
|
||||
homepage: "https://private.com",
|
||||
items: [{ name: "secure-component", type: "registry:ui" }],
|
||||
}
|
||||
|
||||
let receivedHeaders: Record<string, string> = {}
|
||||
server.use(
|
||||
http.get("https://private.com/registry.json", ({ request }) => {
|
||||
// Convert headers to a plain object
|
||||
request.headers.forEach((value, key) => {
|
||||
receivedHeaders[key] = value
|
||||
})
|
||||
return HttpResponse.json(registryData)
|
||||
})
|
||||
)
|
||||
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@private": {
|
||||
url: "https://private.com/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = await getRegistry("@private/registry", mockConfig)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
name: "@private/registry",
|
||||
homepage: "https://private.com",
|
||||
items: [{ name: "secure-component", type: "registry:ui" }],
|
||||
})
|
||||
|
||||
expect(receivedHeaders.authorization).toBe("Bearer test-token")
|
||||
})
|
||||
|
||||
it("should return null on error", async () => {
|
||||
server.use(
|
||||
http.get("https://example.com/registry.json", () => {
|
||||
return HttpResponse.json({ error: "Not found" }, { status: 404 })
|
||||
})
|
||||
)
|
||||
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@example": {
|
||||
url: "https://example.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = await getRegistry("@example/registry", mockConfig)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { homedir } from "os"
|
||||
import path from "path"
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import {
|
||||
clearRegistryContext,
|
||||
getRegistryHeadersFromContext,
|
||||
} from "@/src/registry/context"
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import { resolveRegistryItemsFromRegistries } from "@/src/registry/resolver"
|
||||
import { isLocalFile } from "@/src/registry/utils"
|
||||
import { Config, getTargetStyleFromConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
Config,
|
||||
createConfig,
|
||||
getTargetStyleFromConfig,
|
||||
} from "@/src/utils/get-config"
|
||||
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
@@ -13,17 +24,18 @@ import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import fetch from "node-fetch"
|
||||
import { z } from "zod"
|
||||
|
||||
import { REGISTRY_URL } from "./constants"
|
||||
import {
|
||||
iconsSchema,
|
||||
registryBaseColorSchema,
|
||||
registryIndexSchema,
|
||||
registryItemSchema,
|
||||
registryResolvedItemsTreeSchema,
|
||||
registrySchema,
|
||||
stylesSchema,
|
||||
type RegistryItem,
|
||||
} from "./schema"
|
||||
|
||||
const REGISTRY_URL = process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
|
||||
|
||||
const agent = process.env.https_proxy
|
||||
? new HttpsProxyAgent(process.env.https_proxy)
|
||||
: undefined
|
||||
@@ -86,19 +98,29 @@ export async function getRegistryIcons() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegistryItem(name: string, style: string) {
|
||||
export async function getRegistryItem(
|
||||
name: string,
|
||||
config?: Config
|
||||
): Promise<RegistryItem | null> {
|
||||
try {
|
||||
// Handle local file paths
|
||||
if (isLocalFile(name)) {
|
||||
return await getLocalRegistryItem(name)
|
||||
}
|
||||
|
||||
// Handle URLs and component names
|
||||
const [result] = await fetchRegistry([
|
||||
isUrl(name) ? name : `styles/${style}/${name}.json`,
|
||||
])
|
||||
if (isUrl(name)) {
|
||||
const [result] = await fetchFromRegistry([name], config)
|
||||
return result
|
||||
}
|
||||
|
||||
return registryItemSchema.parse(result)
|
||||
if (name.startsWith("@") && config?.registries) {
|
||||
const [result] = await fetchFromRegistry([name], config)
|
||||
return result
|
||||
}
|
||||
|
||||
// Handle regular component names
|
||||
const path = `styles/${config?.style ?? "new-york-v4"}/${name}.json`
|
||||
const [result] = await fetchFromRegistry([path], config)
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
@@ -126,6 +148,22 @@ async function getLocalRegistryItem(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegistry(name: `${string}/registry`, config?: Config) {
|
||||
try {
|
||||
const results = await fetchFromRegistry([name], config, { useCache: false })
|
||||
|
||||
if (!results?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return registrySchema.parse(results[0])
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegistryBaseColors() {
|
||||
return BASE_COLORS
|
||||
}
|
||||
@@ -225,7 +263,15 @@ export async function fetchRegistry(
|
||||
|
||||
// Store the promise in the cache before awaiting if caching is enabled
|
||||
const fetchPromise = (async () => {
|
||||
const response = await fetch(url, { agent })
|
||||
// Get headers from context for this URL
|
||||
const headers = getRegistryHeadersFromContext(url)
|
||||
|
||||
const response = await fetch(url, {
|
||||
agent,
|
||||
headers: {
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
@@ -236,11 +282,30 @@ export async function fetchRegistry(
|
||||
500: "Internal server error",
|
||||
}
|
||||
|
||||
let errorDetails = ""
|
||||
try {
|
||||
const result = await response.json()
|
||||
if (result && typeof result === "object") {
|
||||
const messages = []
|
||||
if ("error" in result && result.error) {
|
||||
messages.push(`[${result.error}]: `)
|
||||
}
|
||||
if ("message" in result && result.message) {
|
||||
messages.push(result.message)
|
||||
}
|
||||
if (messages.length > 0) {
|
||||
errorDetails = `\n\nServer response: \n${messages.join("")}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse JSON, that's okay
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error(
|
||||
`You are not authorized to access the component at ${highlighter.info(
|
||||
url
|
||||
)}.\nIf this is a remote registry, you may need to authenticate.`
|
||||
)}.\nIf this is a remote registry, you may need to authenticate.${errorDetails}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,7 +313,7 @@ export async function fetchRegistry(
|
||||
throw new Error(
|
||||
`The component at ${highlighter.info(
|
||||
url
|
||||
)} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.`
|
||||
)} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.${errorDetails}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,17 +321,16 @@ export async function fetchRegistry(
|
||||
throw new Error(
|
||||
`You do not have access to the component at ${highlighter.info(
|
||||
url
|
||||
)}.\nIf this is a remote registry, you may need to authenticate or a token.`
|
||||
)}.\nIf this is a remote registry, you may need to authenticate or a token.${errorDetails}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const message =
|
||||
result && typeof result === "object" && "error" in result
|
||||
? result.error
|
||||
: response.statusText || errorMessages[response.status]
|
||||
throw new Error(
|
||||
`Failed to fetch from ${highlighter.info(url)}.\n${message}`
|
||||
`Failed to fetch from ${highlighter.info(url)}.\n${
|
||||
errorDetails ||
|
||||
response.statusText ||
|
||||
errorMessages[response.status]
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,26 +356,79 @@ export function clearRegistryCache() {
|
||||
registryCache.clear()
|
||||
}
|
||||
|
||||
async function getResolvedStyle(config?: Config) {
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const tailwindVersion = await getProjectTailwindVersionFromConfig(config)
|
||||
return tailwindVersion === "v4" && config.style === "new-york"
|
||||
? "new-york-v4"
|
||||
: config.style
|
||||
}
|
||||
|
||||
export async function fetchFromRegistry(
|
||||
items: `${string}/registry`[],
|
||||
config?: Config,
|
||||
options?: { useCache?: boolean }
|
||||
): Promise<z.infer<typeof registrySchema>[]>
|
||||
|
||||
export async function fetchFromRegistry(
|
||||
items: string[],
|
||||
config?: Config,
|
||||
options?: { useCache?: boolean }
|
||||
): Promise<z.infer<typeof registryItemSchema>[]>
|
||||
|
||||
export async function fetchFromRegistry(
|
||||
items: string[],
|
||||
config?: Config,
|
||||
options: { useCache?: boolean } = {}
|
||||
): Promise<
|
||||
(z.infer<typeof registrySchema> | z.infer<typeof registryItemSchema>)[]
|
||||
> {
|
||||
clearRegistryContext()
|
||||
|
||||
const resolvedStyle = await getResolvedStyle(config)
|
||||
const configWithStyle =
|
||||
config && resolvedStyle ? { ...config, style: resolvedStyle } : config
|
||||
const paths = configWithStyle
|
||||
? resolveRegistryItemsFromRegistries(items, configWithStyle)
|
||||
: items
|
||||
|
||||
const results = await fetchRegistry(paths, options)
|
||||
|
||||
return results.map((result, index) => {
|
||||
const originalItem = items[index]
|
||||
const resolvedItem = paths[index]
|
||||
|
||||
if (
|
||||
originalItem.endsWith("/registry") ||
|
||||
resolvedItem.endsWith("/registry.json")
|
||||
) {
|
||||
return registrySchema.parse(result)
|
||||
}
|
||||
|
||||
return registryItemSchema.parse(result)
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveDependenciesRecursively(
|
||||
dependencies: string[],
|
||||
config?: Config,
|
||||
visited: Set<string> = new Set()
|
||||
): Promise<{
|
||||
items: z.infer<typeof registryItemSchema>[]
|
||||
registryNames: string[]
|
||||
}> {
|
||||
) {
|
||||
const items: z.infer<typeof registryItemSchema>[] = []
|
||||
const registryNames: string[] = []
|
||||
|
||||
for (const dep of dependencies) {
|
||||
// Avoid infinite recursion.
|
||||
if (visited.has(dep)) {
|
||||
continue
|
||||
}
|
||||
visited.add(dep)
|
||||
|
||||
// Handle URLs and local files directly
|
||||
if (isUrl(dep) || isLocalFile(dep)) {
|
||||
const item = await getRegistryItem(dep, "")
|
||||
const item = await getRegistryItem(dep, config)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
if (item.registryDependencies) {
|
||||
@@ -324,21 +441,41 @@ async function resolveDependenciesRecursively(
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Registry name - add it to the list
|
||||
}
|
||||
// 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 Error(
|
||||
`The items you're adding depend on unknown registry ${registry}. \nMake sure it is defined in components.json as follows:\n` +
|
||||
`{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}`
|
||||
)
|
||||
}
|
||||
|
||||
// Let getRegistryItem handle the namespaced item with config
|
||||
// This ensures proper authentication headers are used
|
||||
const item = await getRegistryItem(dep, config)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
if (item.registryDependencies) {
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
item.registryDependencies,
|
||||
config,
|
||||
visited
|
||||
)
|
||||
items.push(...nested.items)
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle regular component names
|
||||
else {
|
||||
registryNames.push(dep)
|
||||
|
||||
// If we have config, we can also fetch the item to get its dependencies
|
||||
if (config) {
|
||||
const style = config.resolvedPaths?.cwd
|
||||
? await getTargetStyleFromConfig(
|
||||
config.resolvedPaths.cwd,
|
||||
config.style
|
||||
)
|
||||
: config.style
|
||||
|
||||
try {
|
||||
const item = await getRegistryItem(dep, style)
|
||||
const item = await getRegistryItem(dep, config)
|
||||
if (item && item.registryDependencies) {
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
item.registryDependencies,
|
||||
@@ -349,7 +486,7 @@ async function resolveDependenciesRecursively(
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't fetch the registry item, that's okay - we'll still include the name
|
||||
// If we can't fetch the registry item, that's okay - we'll still include the name.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,6 +500,19 @@ export async function registryResolveItemsTree(
|
||||
config: Config
|
||||
) {
|
||||
try {
|
||||
// Check for namespaced items when no registries are configured
|
||||
const namespacedItems = names.filter(
|
||||
(name) => !isLocalFile(name) && !isUrl(name) && name.startsWith("@")
|
||||
)
|
||||
|
||||
if (namespacedItems.length > 0 && !config?.registries) {
|
||||
const { registry } = parseRegistryAndItemFromString(namespacedItems[0])
|
||||
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}`
|
||||
)
|
||||
}
|
||||
|
||||
// Separate local files, URLs, and registry names.
|
||||
const localFiles = names.filter((name) => isLocalFile(name))
|
||||
const urls = names.filter((name) => isUrl(name))
|
||||
@@ -372,60 +522,161 @@ export async function registryResolveItemsTree(
|
||||
|
||||
const payload: z.infer<typeof registryItemSchema>[] = []
|
||||
|
||||
// Handle local files and URLs directly, collecting their dependencies.
|
||||
const allDependencies: string[] = []
|
||||
// Handle local files and URLs directly, resolving their dependencies individually.
|
||||
let allDependencyItems: z.infer<typeof registryItemSchema>[] = []
|
||||
let allDependencyRegistryNames: string[] = []
|
||||
|
||||
const resolvedStyle = await getResolvedStyle(config)
|
||||
const configWithStyle =
|
||||
config && resolvedStyle ? { ...config, style: resolvedStyle } : config
|
||||
|
||||
for (const localFile of localFiles) {
|
||||
const item = await getRegistryItem(localFile, "")
|
||||
const item = await getRegistryItem(localFile)
|
||||
if (item) {
|
||||
payload.push(item)
|
||||
if (item.registryDependencies) {
|
||||
allDependencies.push(...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) =>
|
||||
dep.startsWith("@")
|
||||
)
|
||||
if (namespacedDeps.length > 0) {
|
||||
const { registry } = parseRegistryAndItemFromString(
|
||||
namespacedDeps[0]
|
||||
)
|
||||
throw new Error(
|
||||
`The items you're adding depend on unknown registry ${registry}. \nMake sure it is defined in components.json as follows:\n` +
|
||||
`{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
resolvedDependencies = resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
configWithStyle
|
||||
)
|
||||
}
|
||||
|
||||
const { items, registryNames } = await resolveDependenciesRecursively(
|
||||
resolvedDependencies,
|
||||
config,
|
||||
new Set()
|
||||
)
|
||||
allDependencyItems.push(...items)
|
||||
allDependencyRegistryNames.push(...registryNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
const item = await getRegistryItem(url, "")
|
||||
const item = await getRegistryItem(url, config)
|
||||
if (item) {
|
||||
payload.push(item)
|
||||
if (item.registryDependencies) {
|
||||
allDependencies.push(...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) =>
|
||||
dep.startsWith("@")
|
||||
)
|
||||
if (namespacedDeps.length > 0) {
|
||||
const { registry } = parseRegistryAndItemFromString(
|
||||
namespacedDeps[0]
|
||||
)
|
||||
throw new Error(
|
||||
`The items you're adding depend on unknown registry ${registry}. \nMake sure it is defined in components.json as follows:\n` +
|
||||
`{\n "registries": {\n "${registry}": "https://example.com/{name}.json"\n }\n}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
resolvedDependencies = resolveRegistryItemsFromRegistries(
|
||||
item.registryDependencies,
|
||||
configWithStyle
|
||||
)
|
||||
}
|
||||
|
||||
const { items, registryNames } = await resolveDependenciesRecursively(
|
||||
resolvedDependencies,
|
||||
config,
|
||||
new Set()
|
||||
)
|
||||
|
||||
allDependencyItems.push(...items)
|
||||
allDependencyRegistryNames.push(...registryNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve all dependencies.
|
||||
const { items: dependencyItems, registryNames: dependencyRegistryNames } =
|
||||
await resolveDependenciesRecursively(allDependencies, config)
|
||||
payload.push(...allDependencyItems)
|
||||
|
||||
payload.push(...dependencyItems)
|
||||
|
||||
// Handle registry names using existing resolveRegistryItems logic.
|
||||
const allRegistryNames = [...registryNames, ...dependencyRegistryNames]
|
||||
// Handle registry names using the new fetchFromRegistry logic.
|
||||
const allRegistryNames = [...registryNames, ...allDependencyRegistryNames]
|
||||
if (allRegistryNames.length > 0) {
|
||||
const index = await getRegistryIndex()
|
||||
if (!index) {
|
||||
// If we only have local files or URLs, that's fine.
|
||||
if (payload.length === 0) {
|
||||
// Separate namespaced and non-namespaced items
|
||||
const nonNamespacedItems = allRegistryNames.filter(
|
||||
(name) => !name.startsWith("@")
|
||||
)
|
||||
const namespacedItems = allRegistryNames.filter((name) =>
|
||||
name.startsWith("@")
|
||||
)
|
||||
|
||||
// Handle namespaced items directly with fetchFromRegistry
|
||||
if (namespacedItems.length > 0) {
|
||||
const results = await fetchFromRegistry(namespacedItems, config)
|
||||
const namespacedPayload = results as z.infer<
|
||||
typeof registryItemSchema
|
||||
>[]
|
||||
payload.push(...namespacedPayload)
|
||||
|
||||
// Process dependencies of namespaced items
|
||||
for (const item of namespacedPayload) {
|
||||
if (item.registryDependencies) {
|
||||
const { items: depItems, registryNames: depNames } =
|
||||
await resolveDependenciesRecursively(
|
||||
item.registryDependencies,
|
||||
config,
|
||||
new Set([...namespacedItems])
|
||||
)
|
||||
payload.push(...depItems)
|
||||
|
||||
// Add any non-namespaced dependencies to be processed
|
||||
const nonNamespacedDeps = depNames.filter(
|
||||
(name) => !name.startsWith("@")
|
||||
)
|
||||
nonNamespacedItems.push(...nonNamespacedDeps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-namespaced items, we need the index and style resolution
|
||||
if (nonNamespacedItems.length > 0) {
|
||||
const index = await getRegistryIndex()
|
||||
if (!index && payload.length === 0) {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// Remove duplicates.
|
||||
const uniqueRegistryNames = Array.from(new Set(allRegistryNames))
|
||||
|
||||
// If we're resolving the index, we want it to go first.
|
||||
if (uniqueRegistryNames.includes("index")) {
|
||||
uniqueRegistryNames.unshift("index")
|
||||
if (index) {
|
||||
// Remove duplicates from non-namespaced items
|
||||
const uniqueNonNamespaced = Array.from(new Set(nonNamespacedItems))
|
||||
|
||||
// If we're resolving the index, we want it to go first
|
||||
if (uniqueNonNamespaced.includes("index")) {
|
||||
uniqueNonNamespaced.unshift("index")
|
||||
}
|
||||
|
||||
// Resolve non-namespaced items through the existing flow
|
||||
let registryItems = await resolveRegistryItems(
|
||||
uniqueNonNamespaced,
|
||||
config
|
||||
)
|
||||
let result = await fetchRegistry(registryItems)
|
||||
const registryPayload = z.array(registryItemSchema).parse(result)
|
||||
payload.push(...registryPayload)
|
||||
}
|
||||
|
||||
let registryItems = await resolveRegistryItems(
|
||||
uniqueRegistryNames,
|
||||
config
|
||||
)
|
||||
let result = await fetchRegistry(registryItems)
|
||||
const registryPayload = z.array(registryItemSchema).parse(result)
|
||||
payload.push(...registryPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,12 +697,16 @@ export async function registryResolveItemsTree(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the payload so that registry:theme is always first.
|
||||
// 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") {
|
||||
if (a.type === "registry:theme" && b.type !== "registry:theme") {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
if (a.type !== "registry:theme" && b.type === "registry:theme") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
let tailwind = {}
|
||||
@@ -506,11 +761,16 @@ export async function registryResolveItemsTree(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRegistryDependencies(
|
||||
url: string,
|
||||
config: Config
|
||||
): Promise<string[]> {
|
||||
const { registryNames } = await resolveDependenciesRecursively([url], config)
|
||||
async function resolveRegistryDependencies(url: string, config: Config) {
|
||||
if (isUrl(url)) {
|
||||
return [url]
|
||||
}
|
||||
|
||||
const { registryNames } = await resolveDependenciesRecursively(
|
||||
[url],
|
||||
config,
|
||||
new Set()
|
||||
)
|
||||
|
||||
const style = config.resolvedPaths?.cwd
|
||||
? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style)
|
||||
@@ -627,14 +887,30 @@ export function isUrl(path: string) {
|
||||
export async function resolveRegistryItems(names: string[], config: Config) {
|
||||
let registryDependencies: string[] = []
|
||||
|
||||
// Filter out local files and URLs - these should be handled directly by getRegistryItem
|
||||
const registryNames = names.filter(
|
||||
(name) => !isLocalFile(name) && !isUrl(name)
|
||||
)
|
||||
|
||||
const resolvedStyle = await getResolvedStyle(config)
|
||||
for (const name of registryNames) {
|
||||
let resolvedName = name
|
||||
if (config) {
|
||||
try {
|
||||
const configWithStyle =
|
||||
config && resolvedStyle ? { ...config, style: resolvedStyle } : config
|
||||
|
||||
const resolved = buildUrlAndHeadersForRegistryItem(
|
||||
name,
|
||||
configWithStyle
|
||||
)
|
||||
if (resolved) {
|
||||
resolvedName = resolved.url
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const itemRegistryDependencies = await resolveRegistryDependencies(
|
||||
name,
|
||||
resolvedName,
|
||||
config
|
||||
)
|
||||
registryDependencies.push(...itemRegistryDependencies)
|
||||
|
||||
449
packages/shadcn/src/registry/builder.test.ts
Normal file
449
packages/shadcn/src/registry/builder.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildHeadersFromRegistryConfig,
|
||||
buildUrlAndHeadersForRegistryItem,
|
||||
buildUrlFromRegistryConfig,
|
||||
} from "./builder"
|
||||
|
||||
describe("buildUrlFromRegistryConfig", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_TOKEN = "abc123"
|
||||
process.env.API_VERSION = "v2"
|
||||
process.env.API_KEY = "key456"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TEST_TOKEN
|
||||
delete process.env.API_VERSION
|
||||
delete process.env.API_KEY
|
||||
})
|
||||
|
||||
it("should build URL from string config", () => {
|
||||
const url = buildUrlFromRegistryConfig(
|
||||
"chat-component",
|
||||
"https://v0.dev/chat/b/{name}/json"
|
||||
)
|
||||
expect(url).toBe("https://v0.dev/chat/b/chat-component/json")
|
||||
})
|
||||
|
||||
it("should replace style placeholder in URL", () => {
|
||||
const url = buildUrlFromRegistryConfig(
|
||||
"button",
|
||||
"https://ui.shadcn.com/r/styles/{style}/{name}.json",
|
||||
{ style: "new-york" } as any
|
||||
)
|
||||
expect(url).toBe("https://ui.shadcn.com/r/styles/new-york/button.json")
|
||||
})
|
||||
|
||||
it("should handle both name and style placeholders", () => {
|
||||
const url = buildUrlFromRegistryConfig(
|
||||
"accordion",
|
||||
"https://example.com/{style}/components/{name}",
|
||||
{ style: "default" } as any
|
||||
)
|
||||
expect(url).toBe("https://example.com/default/components/accordion")
|
||||
})
|
||||
|
||||
it("should build URL with env vars", () => {
|
||||
const url = buildUrlFromRegistryConfig(
|
||||
"button",
|
||||
"https://api.com/{name}?token=${TEST_TOKEN}"
|
||||
)
|
||||
expect(url).toBe("https://api.com/button?token=abc123")
|
||||
})
|
||||
|
||||
it("should build URL with params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
params: {
|
||||
version: "${API_VERSION}",
|
||||
format: "json",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table?version=v2&format=json")
|
||||
})
|
||||
|
||||
it("should skip empty param values", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
params: {
|
||||
token: "${MISSING_VAR}",
|
||||
format: "json",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table?format=json")
|
||||
})
|
||||
|
||||
it("should handle existing query params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}?existing=true",
|
||||
params: {
|
||||
new: "param",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table?existing=true&new=param")
|
||||
})
|
||||
|
||||
it("should handle URL with no params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table")
|
||||
})
|
||||
|
||||
it("should handle multiple env vars in params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
params: {
|
||||
token: "${TEST_TOKEN}",
|
||||
version: "${API_VERSION}",
|
||||
key: "${API_KEY}",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table?token=abc123&version=v2&key=key456")
|
||||
})
|
||||
|
||||
it("should handle all empty env vars in params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
params: {
|
||||
token: "${MISSING_VAR1}",
|
||||
key: "${MISSING_VAR2}",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table")
|
||||
})
|
||||
|
||||
it("should handle mixed static and env var params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
params: {
|
||||
static: "value",
|
||||
env: "${TEST_TOKEN}",
|
||||
empty: "${MISSING_VAR}",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe("https://api.com/table?static=value&env=abc123")
|
||||
})
|
||||
|
||||
it("should handle special characters in params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
params: {
|
||||
"user-id": "123",
|
||||
"api-key": "${TEST_TOKEN}",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe(
|
||||
"https://api.com/table?user-id=123&api-key=abc123&content-type=application%2Fjson"
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle URL with multiple existing query params", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}?param1=value1¶m2=value2",
|
||||
params: {
|
||||
newParam: "newValue",
|
||||
envParam: "${TEST_TOKEN}",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe(
|
||||
"https://api.com/table?param1=value1¶m2=value2&newParam=newValue&envParam=abc123"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildHeadersFromRegistryConfig", () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_TOKEN = "secret123"
|
||||
process.env.CLIENT_ID = "client456"
|
||||
process.env.API_KEY = "key789"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.AUTH_TOKEN
|
||||
delete process.env.CLIENT_ID
|
||||
delete process.env.API_KEY
|
||||
})
|
||||
|
||||
it("should return empty object for string config", () => {
|
||||
expect(buildHeadersFromRegistryConfig("https://api.com/{name}")).toEqual({})
|
||||
})
|
||||
|
||||
it("should return empty object for config without headers", () => {
|
||||
expect(
|
||||
buildHeadersFromRegistryConfig({ url: "https://api.com/{name}" })
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it("should expand headers with env vars", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${AUTH_TOKEN}",
|
||||
"X-Client-Id": "${CLIENT_ID}",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
Authorization: "Bearer secret123",
|
||||
"X-Client-Id": "client456",
|
||||
})
|
||||
})
|
||||
|
||||
it("should skip headers with empty values", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${MISSING_VAR}",
|
||||
"X-Client-Id": "${CLIENT_ID}",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"X-Client-Id": "client456",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle headers with mixed static and env var content", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${AUTH_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom": "prefix-${CLIENT_ID}-suffix",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
Authorization: "Bearer secret123",
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom": "prefix-client456-suffix",
|
||||
})
|
||||
})
|
||||
|
||||
it("should skip headers with only env vars that are empty", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
"X-Missing": "${MISSING_VAR1}",
|
||||
"X-Also-Missing": "${MISSING_VAR2}",
|
||||
"X-Present": "${CLIENT_ID}",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"X-Present": "client456",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle headers with whitespace-only values", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
"X-Empty": " ",
|
||||
"X-Whitespace": " ${MISSING_VAR} ",
|
||||
"X-Valid": " ${CLIENT_ID} ",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"X-Valid": " client456 ",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle complex env var patterns", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
"X-Complex": "Bearer ${AUTH_TOKEN} with ${CLIENT_ID} and ${API_KEY}",
|
||||
"X-Simple": "${CLIENT_ID}",
|
||||
"X-Mixed": "static-${AUTH_TOKEN}-${MISSING_VAR}-${API_KEY}",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"X-Complex": "Bearer secret123 with client456 and key789",
|
||||
"X-Simple": "client456",
|
||||
"X-Mixed": "static-secret123--key789",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle headers with only static content", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "shadcn-ui/1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "shadcn-ui/1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle headers with template-like content but no env vars", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
"X-Template": "This is a template ${but not an env var}",
|
||||
"X-Regular": "Regular header value",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"X-Template": "This is a template ${but not an env var}",
|
||||
"X-Regular": "Regular header value",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle case where env var expansion doesn't change the value", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
"X-No-Change": "static value",
|
||||
"X-With-Env": "prefix-${CLIENT_ID}-suffix",
|
||||
},
|
||||
}
|
||||
|
||||
expect(buildHeadersFromRegistryConfig(config)).toEqual({
|
||||
"X-No-Change": "static value",
|
||||
"X-With-Env": "prefix-client456-suffix",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildUrlAndHeadersForRegistryItem", () => {
|
||||
it("should return null for non-registry items", () => {
|
||||
const input = "button"
|
||||
const config = {} as any
|
||||
expect(buildUrlAndHeadersForRegistryItem(input, config)).toBeNull()
|
||||
})
|
||||
|
||||
it("should throw error for unknown registry", () => {
|
||||
expect(() => {
|
||||
buildUrlAndHeadersForRegistryItem("@unknown/button", {} as any)
|
||||
}).toThrow('Unknown registry "@unknown"')
|
||||
})
|
||||
|
||||
it("should resolve registry items with string config", () => {
|
||||
const config = {
|
||||
registries: {
|
||||
"@v0": "https://v0.dev/chat/b/{name}/json",
|
||||
},
|
||||
} as any
|
||||
|
||||
const result1 = buildUrlAndHeadersForRegistryItem("@v0/button", config)
|
||||
expect(result1).toEqual({
|
||||
url: "https://v0.dev/chat/b/button/json",
|
||||
headers: {},
|
||||
})
|
||||
|
||||
const result2 = buildUrlAndHeadersForRegistryItem("@v0/data-table", config)
|
||||
expect(result2).toEqual({
|
||||
url: "https://v0.dev/chat/b/data-table/json",
|
||||
headers: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("should resolve registry items with object config", () => {
|
||||
const config = {
|
||||
registries: {
|
||||
"@test": {
|
||||
url: "https://api.com/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer token123",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = buildUrlAndHeadersForRegistryItem("@test/button", config)
|
||||
expect(result).toEqual({
|
||||
url: "https://api.com/button.json",
|
||||
headers: {
|
||||
Authorization: "Bearer token123",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle environment variables in config", () => {
|
||||
process.env.TEST_TOKEN = "abc123"
|
||||
process.env.API_URL = "https://api.com"
|
||||
|
||||
const config = {
|
||||
registries: {
|
||||
"@env": {
|
||||
url: "${API_URL}/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer ${TEST_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = buildUrlAndHeadersForRegistryItem("@env/button", config)
|
||||
expect(result).toEqual({
|
||||
url: "https://api.com/button.json",
|
||||
headers: {
|
||||
Authorization: "Bearer abc123",
|
||||
},
|
||||
})
|
||||
|
||||
delete process.env.TEST_TOKEN
|
||||
delete process.env.API_URL
|
||||
})
|
||||
|
||||
it("should handle complex item paths", () => {
|
||||
const config = {
|
||||
registries: {
|
||||
"@acme": "https://api.com/{name}.json",
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = buildUrlAndHeadersForRegistryItem("@acme/ui/button", config)
|
||||
expect(result).toEqual({
|
||||
url: "https://api.com/ui/button.json",
|
||||
headers: {},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle URLs and local files", () => {
|
||||
const config = { registries: {} } as any
|
||||
|
||||
// URLs should return null (not registry items)
|
||||
expect(
|
||||
buildUrlAndHeadersForRegistryItem("https://example.com/button", config)
|
||||
).toBeNull()
|
||||
|
||||
// Local files should return null (not registry items)
|
||||
expect(
|
||||
buildUrlAndHeadersForRegistryItem("./local/button", config)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
131
packages/shadcn/src/registry/builder.ts
Normal file
131
packages/shadcn/src/registry/builder.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
|
||||
import { configSchema, registryConfigItemSchema } from "@/src/registry/schema"
|
||||
import { validateRegistryConfig } from "@/src/registry/validator"
|
||||
import { z } from "zod"
|
||||
|
||||
import { expandEnvVars } from "./env"
|
||||
|
||||
const NAME_PLACEHOLDER = "{name}"
|
||||
const STYLE_PLACEHOLDER = "{style}"
|
||||
const ENV_VAR_PATTERN = /\${(\w+)}/g
|
||||
const QUERY_PARAM_SEPARATOR = "?"
|
||||
const QUERY_PARAM_DELIMITER = "&"
|
||||
|
||||
export function buildUrlAndHeadersForRegistryItem(
|
||||
name: string,
|
||||
config?: z.infer<typeof configSchema>
|
||||
) {
|
||||
const { registry, item } = parseRegistryAndItemFromString(name)
|
||||
|
||||
if (!registry) {
|
||||
return null
|
||||
}
|
||||
|
||||
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}`
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: I don't like this here.
|
||||
// But this will do for now.
|
||||
validateRegistryConfig(registry, registryConfig)
|
||||
|
||||
return {
|
||||
url: buildUrlFromRegistryConfig(item, registryConfig, config),
|
||||
headers: buildHeadersFromRegistryConfig(registryConfig),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUrlFromRegistryConfig(
|
||||
item: string,
|
||||
registryConfig: z.infer<typeof registryConfigItemSchema>,
|
||||
config?: z.infer<typeof configSchema>
|
||||
) {
|
||||
if (typeof registryConfig === "string") {
|
||||
let url = registryConfig.replace(NAME_PLACEHOLDER, item)
|
||||
if (config?.style && url.includes(STYLE_PLACEHOLDER)) {
|
||||
url = url.replace(STYLE_PLACEHOLDER, config.style)
|
||||
}
|
||||
return expandEnvVars(url)
|
||||
}
|
||||
|
||||
let baseUrl = registryConfig.url.replace(NAME_PLACEHOLDER, item)
|
||||
if (config?.style && baseUrl.includes(STYLE_PLACEHOLDER)) {
|
||||
baseUrl = baseUrl.replace(STYLE_PLACEHOLDER, config.style)
|
||||
}
|
||||
baseUrl = expandEnvVars(baseUrl)
|
||||
|
||||
if (!registryConfig.params) {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
return appendQueryParams(baseUrl, registryConfig.params)
|
||||
}
|
||||
|
||||
export function buildHeadersFromRegistryConfig(
|
||||
config: z.infer<typeof registryConfigItemSchema>
|
||||
) {
|
||||
if (typeof config === "string" || !config.headers) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(config.headers)) {
|
||||
const expandedValue = expandEnvVars(value)
|
||||
|
||||
if (shouldIncludeHeader(value, expandedValue)) {
|
||||
headers[key] = expandedValue
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
function appendQueryParams(baseUrl: string, params: Record<string, string>) {
|
||||
const urlParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const expandedValue = expandEnvVars(value)
|
||||
if (expandedValue) {
|
||||
urlParams.append(key, expandedValue)
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = urlParams.toString()
|
||||
if (!queryString) {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
const separator = baseUrl.includes(QUERY_PARAM_SEPARATOR)
|
||||
? QUERY_PARAM_DELIMITER
|
||||
: QUERY_PARAM_SEPARATOR
|
||||
|
||||
return `${baseUrl}${separator}${queryString}`
|
||||
}
|
||||
|
||||
function shouldIncludeHeader(originalValue: string, expandedValue: string) {
|
||||
const trimmedExpanded = expandedValue.trim()
|
||||
|
||||
if (!trimmedExpanded) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the original value contains valid env vars, only include if expansion changed the value.
|
||||
if (originalValue.includes("${")) {
|
||||
// Check if there are actual env vars in the string
|
||||
const envVars = originalValue.match(ENV_VAR_PATTERN)
|
||||
if (envVars) {
|
||||
const templateWithoutVars = originalValue
|
||||
.replace(ENV_VAR_PATTERN, "")
|
||||
.trim()
|
||||
return trimmedExpanded !== templateWithoutVars
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { registryConfigSchema } from "./schema"
|
||||
|
||||
export const REGISTRY_URL =
|
||||
process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
|
||||
|
||||
// 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`,
|
||||
}
|
||||
|
||||
export const BUILTIN_MODULES = new Set([
|
||||
[
|
||||
// Node.js built-in modules
|
||||
|
||||
23
packages/shadcn/src/registry/context.ts
Normal file
23
packages/shadcn/src/registry/context.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
interface RegistryContext {
|
||||
headers: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
let context: RegistryContext = {
|
||||
headers: {},
|
||||
}
|
||||
|
||||
export function setRegistryHeaders(
|
||||
headers: Record<string, Record<string, string>>
|
||||
) {
|
||||
context.headers = headers
|
||||
}
|
||||
|
||||
export function getRegistryHeadersFromContext(
|
||||
url: string
|
||||
): Record<string, string> {
|
||||
return context.headers[url] || {}
|
||||
}
|
||||
|
||||
export function clearRegistryContext() {
|
||||
context.headers = {}
|
||||
}
|
||||
46
packages/shadcn/src/registry/env.test.ts
Normal file
46
packages/shadcn/src/registry/env.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { expandEnvVars, extractEnvVars } from "./env"
|
||||
|
||||
describe("expandEnvVars", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_TOKEN = "abc123"
|
||||
process.env.API_KEY = "secret"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TEST_TOKEN
|
||||
delete process.env.API_KEY
|
||||
})
|
||||
|
||||
it("should expand environment variables", () => {
|
||||
expect(expandEnvVars("Bearer ${TEST_TOKEN}")).toBe("Bearer abc123")
|
||||
expect(expandEnvVars("key=${API_KEY}&token=${TEST_TOKEN}")).toBe(
|
||||
"key=secret&token=abc123"
|
||||
)
|
||||
})
|
||||
|
||||
it("should replace missing env vars with empty string", () => {
|
||||
expect(expandEnvVars("Bearer ${MISSING_VAR}")).toBe("Bearer ")
|
||||
expect(expandEnvVars("${VAR1}:${VAR2}")).toBe(":")
|
||||
})
|
||||
|
||||
it("should handle strings without env vars", () => {
|
||||
expect(expandEnvVars("no variables here")).toBe("no variables here")
|
||||
expect(expandEnvVars("https://example.com")).toBe("https://example.com")
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractEnvVars", () => {
|
||||
it("should extract environment variable names", () => {
|
||||
expect(extractEnvVars("Bearer ${TOKEN}")).toEqual(["TOKEN"])
|
||||
expect(extractEnvVars("${VAR1} and ${VAR2}")).toEqual(["VAR1", "VAR2"])
|
||||
expect(extractEnvVars("${SAME} and ${SAME}")).toEqual(["SAME", "SAME"])
|
||||
})
|
||||
|
||||
it("should return empty array for no variables", () => {
|
||||
expect(extractEnvVars("no variables")).toEqual([])
|
||||
expect(extractEnvVars("")).toEqual([])
|
||||
})
|
||||
})
|
||||
15
packages/shadcn/src/registry/env.ts
Normal file
15
packages/shadcn/src/registry/env.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function expandEnvVars(value: string) {
|
||||
return value.replace(/\${(\w+)}/g, (_match, key) => process.env[key] || "")
|
||||
}
|
||||
|
||||
export function extractEnvVars(value: string) {
|
||||
const vars: string[] = []
|
||||
const regex = /\${(\w+)}/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
vars.push(match[1])
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export {
|
||||
registryResolveItemsTree as internal_registryResolveItemsTree,
|
||||
fetchRegistry,
|
||||
} from "./api"
|
||||
export { BUILTIN_REGISTRIES, REGISTRY_URL } from "./constants"
|
||||
export { buildUrlAndHeadersForRegistryItem } from "./builder"
|
||||
|
||||
252
packages/shadcn/src/registry/parser.test.ts
Normal file
252
packages/shadcn/src/registry/parser.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parseRegistryAndItemFromString } from "./parser"
|
||||
|
||||
describe("parseRegistryAndItemFromString", () => {
|
||||
describe("valid registry items", () => {
|
||||
it.each([
|
||||
["@v0/button", { registry: "@v0", item: "button" }],
|
||||
["@acme/data-table", { registry: "@acme", item: "data-table" }],
|
||||
[
|
||||
"@company/nested/component",
|
||||
{ registry: "@company", item: "nested/component" },
|
||||
],
|
||||
["@test/simple", { registry: "@test", item: "simple" }],
|
||||
["@my-registry/item", { registry: "@my-registry", item: "item" }],
|
||||
["@my_registry/item", { registry: "@my_registry", item: "item" }],
|
||||
["@123registry/item", { registry: "@123registry", item: "item" }],
|
||||
["@registry123/item", { registry: "@registry123", item: "item" }],
|
||||
["@r/item", { registry: "@r", item: "item" }],
|
||||
[
|
||||
"@very-long-registry-name/item",
|
||||
{ registry: "@very-long-registry-name", item: "item" },
|
||||
],
|
||||
])("should parse registry item: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-registry items", () => {
|
||||
it.each([
|
||||
["button", { registry: null, item: "button" }],
|
||||
["components/button", { registry: null, item: "components/button" }],
|
||||
["v0/button", { registry: null, item: "v0/button" }],
|
||||
["@button", { registry: null, item: "@button" }],
|
||||
["button@", { registry: null, item: "button@" }],
|
||||
["@", { registry: null, item: "@" }],
|
||||
["@/button", { registry: null, item: "@/button" }],
|
||||
["@-registry/item", { registry: null, item: "@-registry/item" }],
|
||||
["@registry-/item", { registry: null, item: "@registry-/item" }],
|
||||
["@-registry-/item", { registry: null, item: "@-registry-/item" }],
|
||||
])(
|
||||
"should return null registry for non-registry item: %s",
|
||||
(input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe("URLs and external paths", () => {
|
||||
it.each([
|
||||
[
|
||||
"https://example.com/button",
|
||||
{ registry: null, item: "https://example.com/button" },
|
||||
],
|
||||
[
|
||||
"http://localhost:3000/component",
|
||||
{ registry: null, item: "http://localhost:3000/component" },
|
||||
],
|
||||
[
|
||||
"file:///path/to/component",
|
||||
{ registry: null, item: "file:///path/to/component" },
|
||||
],
|
||||
[
|
||||
"ftp://example.com/file",
|
||||
{ registry: null, item: "ftp://example.com/file" },
|
||||
],
|
||||
[
|
||||
"//cdn.example.com/component",
|
||||
{ registry: null, item: "//cdn.example.com/component" },
|
||||
],
|
||||
])("should handle URLs: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("complex item paths", () => {
|
||||
it.each([
|
||||
["@acme/ui/button", { registry: "@acme", item: "ui/button" }],
|
||||
[
|
||||
"@company/components/forms/input",
|
||||
{ registry: "@company", item: "components/forms/input" },
|
||||
],
|
||||
[
|
||||
"@test/nested/deep/path/component",
|
||||
{ registry: "@test", item: "nested/deep/path/component" },
|
||||
],
|
||||
[
|
||||
"@registry/path/with/multiple/slashes",
|
||||
{ registry: "@registry", item: "path/with/multiple/slashes" },
|
||||
],
|
||||
])("should handle complex item paths: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases and special characters", () => {
|
||||
it.each([
|
||||
["", { registry: null, item: "" }],
|
||||
["@", { registry: null, item: "@" }],
|
||||
["@@", { registry: null, item: "@@" }],
|
||||
["@@@", { registry: null, item: "@@@" }],
|
||||
["@/", { registry: null, item: "@/" }],
|
||||
["@//", { registry: null, item: "@//" }],
|
||||
["@/item", { registry: null, item: "@/item" }],
|
||||
["@registry/", { registry: null, item: "@registry/" }],
|
||||
["@registry//", { registry: "@registry", item: "/" }],
|
||||
["@registry///", { registry: "@registry", item: "//" }],
|
||||
])("should handle edge cases: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("registry names with special characters", () => {
|
||||
it.each([
|
||||
["@my-registry/item", { registry: "@my-registry", item: "item" }],
|
||||
["@my_registry/item", { registry: "@my_registry", item: "item" }],
|
||||
[
|
||||
"@my-registry-name/item",
|
||||
{ registry: "@my-registry-name", item: "item" },
|
||||
],
|
||||
[
|
||||
"@my_registry_name/item",
|
||||
{ registry: "@my_registry_name", item: "item" },
|
||||
],
|
||||
[
|
||||
"@my-registry_name/item",
|
||||
{ registry: "@my-registry_name", item: "item" },
|
||||
],
|
||||
[
|
||||
"@my_registry-name/item",
|
||||
{ registry: "@my_registry-name", item: "item" },
|
||||
],
|
||||
["@123-registry/item", { registry: "@123-registry", item: "item" }],
|
||||
["@registry-123/item", { registry: "@registry-123", item: "item" }],
|
||||
["@123_registry/item", { registry: "@123_registry", item: "item" }],
|
||||
["@registry_123/item", { registry: "@registry_123", item: "item" }],
|
||||
])(
|
||||
"should handle registry names with special characters: %s",
|
||||
(input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe("invalid registry patterns", () => {
|
||||
it.each([
|
||||
["@-registry/item", { registry: null, item: "@-registry/item" }],
|
||||
["@registry-/item", { registry: null, item: "@registry-/item" }],
|
||||
["@-registry-/item", { registry: null, item: "@-registry-/item" }],
|
||||
["@-item", { registry: null, item: "@-item" }],
|
||||
["@item-", { registry: null, item: "@item-" }],
|
||||
["@-item-", { registry: null, item: "@-item-" }],
|
||||
["@_registry/item", { registry: null, item: "@_registry/item" }],
|
||||
["@registry_/item", { registry: null, item: "@registry_/item" }],
|
||||
["@_registry_/item", { registry: null, item: "@_registry_/item" }],
|
||||
["@_item", { registry: null, item: "@_item" }],
|
||||
["@item_", { registry: null, item: "@item_" }],
|
||||
["@_item_", { registry: null, item: "@_item_" }],
|
||||
])("should reject invalid registry patterns: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("whitespace and formatting", () => {
|
||||
it.each([
|
||||
[" @v0/button", { registry: null, item: " @v0/button" }],
|
||||
["@v0/button ", { registry: "@v0", item: "button " }],
|
||||
[" @v0/button ", { registry: null, item: " @v0/button " }],
|
||||
["\t@v0/button", { registry: null, item: "\t@v0/button" }],
|
||||
["@v0/button\t", { registry: "@v0", item: "button\t" }],
|
||||
["\n@v0/button", { registry: null, item: "\n@v0/button" }],
|
||||
["@v0/button\n", { registry: null, item: "@v0/button\n" }],
|
||||
])("should handle whitespace: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("case sensitivity", () => {
|
||||
it.each([
|
||||
["@V0/button", { registry: "@V0", item: "button" }],
|
||||
["@v0/BUTTON", { registry: "@v0", item: "BUTTON" }],
|
||||
["@V0/BUTTON", { registry: "@V0", item: "BUTTON" }],
|
||||
["@MyRegistry/item", { registry: "@MyRegistry", item: "item" }],
|
||||
["@MYREGISTRY/item", { registry: "@MYREGISTRY", item: "item" }],
|
||||
["@myregistry/ITEM", { registry: "@myregistry", item: "ITEM" }],
|
||||
])("should be case sensitive: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("numbers and mixed content", () => {
|
||||
it.each([
|
||||
["@123/item", { registry: "@123", item: "item" }],
|
||||
["@registry123/item", { registry: "@registry123", item: "item" }],
|
||||
["@123registry/item", { registry: "@123registry", item: "item" }],
|
||||
["@r123/item", { registry: "@r123", item: "item" }],
|
||||
["@123r/item", { registry: "@123r", item: "item" }],
|
||||
["@item123/item", { registry: "@item123", item: "item" }],
|
||||
["@123-item/item", { registry: "@123-item", item: "item" }],
|
||||
["@item-123/item", { registry: "@item-123", item: "item" }],
|
||||
["@123_item/item", { registry: "@123_item", item: "item" }],
|
||||
["@item_123/item", { registry: "@item_123", item: "item" }],
|
||||
])("should handle numbers and mixed content: %s", (input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("single character cases", () => {
|
||||
it.each([
|
||||
["@a/b", { registry: "@a", item: "b" }],
|
||||
["@a/", { registry: null, item: "@a/" }],
|
||||
["@a//", { registry: "@a", item: "/" }],
|
||||
["@1/b", { registry: "@1", item: "b" }],
|
||||
["@1/", { registry: null, item: "@1/" }],
|
||||
["@1//", { registry: "@1", item: "/" }],
|
||||
])(
|
||||
"should handle single character registry names: %s",
|
||||
(input, expected) => {
|
||||
expect(parseRegistryAndItemFromString(input)).toEqual(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe("very long inputs", () => {
|
||||
it("should handle very long registry names", () => {
|
||||
const longRegistry = "@" + "a".repeat(100)
|
||||
const result = parseRegistryAndItemFromString(longRegistry + "/item")
|
||||
expect(result).toEqual({
|
||||
registry: longRegistry,
|
||||
item: "item",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle very long item paths", () => {
|
||||
const longItem = "a".repeat(100)
|
||||
const result = parseRegistryAndItemFromString("@registry/" + longItem)
|
||||
expect(result).toEqual({
|
||||
registry: "@registry",
|
||||
item: longItem,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle very long non-registry paths", () => {
|
||||
const longPath = "a".repeat(100)
|
||||
const result = parseRegistryAndItemFromString(longPath)
|
||||
expect(result).toEqual({
|
||||
registry: null,
|
||||
item: longPath,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
24
packages/shadcn/src/registry/parser.ts
Normal file
24
packages/shadcn/src/registry/parser.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Valid registry name pattern: @namespace where namespace is alphanumeric with hyphens/underscores
|
||||
const REGISTRY_PATTERN = /^(@[a-zA-Z0-9](?:[a-zA-Z0-9-_]*[a-zA-Z0-9])?)\/(.+)$/
|
||||
|
||||
export function parseRegistryAndItemFromString(name: string) {
|
||||
if (!name.startsWith("@")) {
|
||||
return {
|
||||
registry: null,
|
||||
item: name,
|
||||
}
|
||||
}
|
||||
|
||||
const match = name.match(REGISTRY_PATTERN)
|
||||
if (match) {
|
||||
return {
|
||||
registry: match[1],
|
||||
item: match[2],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registry: null,
|
||||
item: name,
|
||||
}
|
||||
}
|
||||
345
packages/shadcn/src/registry/resolver.test.ts
Normal file
345
packages/shadcn/src/registry/resolver.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { setRegistryHeaders } from "./context"
|
||||
import { resolveRegistryItemsFromRegistries } from "./resolver"
|
||||
|
||||
// Mock the context module
|
||||
vi.mock("./context", () => ({
|
||||
setRegistryHeaders: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("resolveRegistryItemsFromRegistries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should return empty array for empty input", () => {
|
||||
const result = resolveRegistryItemsFromRegistries([], {
|
||||
registries: {},
|
||||
} as any)
|
||||
expect(result).toEqual([])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should return empty array for empty input with no registries", () => {
|
||||
const result = resolveRegistryItemsFromRegistries([], undefined)
|
||||
expect(result).toEqual([])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should return non-registry items unchanged", () => {
|
||||
const items = ["button", "card", "dialog"]
|
||||
const config = { registries: {} } as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual(items)
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should resolve registry items with string config", () => {
|
||||
const items = ["@v0/button", "@v0/card"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@v0": "https://v0.dev/chat/b/{name}/json",
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://v0.dev/chat/b/button/json",
|
||||
"https://v0.dev/chat/b/card/json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should resolve registry items with object config and headers", () => {
|
||||
const items = ["@private/button", "@private/card"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@private": {
|
||||
url: "https://api.com/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer token123",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://api.com/button.json",
|
||||
"https://api.com/card.json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({
|
||||
"https://api.com/button.json": {
|
||||
Authorization: "Bearer token123",
|
||||
},
|
||||
"https://api.com/card.json": {
|
||||
Authorization: "Bearer token123",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle mixed registry and non-registry items", () => {
|
||||
const items = ["button", "@v0/card", "dialog", "@private/table"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@v0": "https://v0.dev/chat/b/{name}/json",
|
||||
"@private": {
|
||||
url: "https://api.com/{name}.json",
|
||||
headers: {
|
||||
"X-API-Key": "secret123",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"button",
|
||||
"https://v0.dev/chat/b/card/json",
|
||||
"dialog",
|
||||
"https://api.com/table.json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({
|
||||
"https://api.com/table.json": {
|
||||
"X-API-Key": "secret123",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle environment variables in config", () => {
|
||||
process.env.API_TOKEN = "abc123"
|
||||
process.env.API_URL = "https://api.com"
|
||||
|
||||
const items = ["@env/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@env": {
|
||||
url: "${API_URL}/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer ${API_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual(["https://api.com/button.json"])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({
|
||||
"https://api.com/button.json": {
|
||||
Authorization: "Bearer abc123",
|
||||
},
|
||||
})
|
||||
|
||||
delete process.env.API_TOKEN
|
||||
delete process.env.API_URL
|
||||
})
|
||||
|
||||
it("should handle complex item paths", () => {
|
||||
const items = ["@acme/ui/button", "@acme/components/card"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@acme": "https://api.com/{name}.json",
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://api.com/ui/button.json",
|
||||
"https://api.com/components/card.json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should handle URLs and local files unchanged", () => {
|
||||
const items = [
|
||||
"https://example.com/button.json",
|
||||
"./local/component.json",
|
||||
"@v0/card",
|
||||
]
|
||||
const config = {
|
||||
registries: {
|
||||
"@v0": "https://v0.dev/chat/b/{name}/json",
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://example.com/button.json",
|
||||
"./local/component.json",
|
||||
"https://v0.dev/chat/b/card/json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should throw error for unknown registry", () => {
|
||||
const items = ["@unknown/button"]
|
||||
const config = { registries: {} } as any
|
||||
|
||||
expect(() => {
|
||||
resolveRegistryItemsFromRegistries(items, config)
|
||||
}).toThrow('Unknown registry "@unknown"')
|
||||
})
|
||||
|
||||
it("should handle multiple unknown registries", () => {
|
||||
const items = ["@unknown1/button", "@unknown2/card"]
|
||||
const config = { registries: {} } as any
|
||||
|
||||
expect(() => {
|
||||
resolveRegistryItemsFromRegistries(items, config)
|
||||
}).toThrow('Unknown registry "@unknown1"')
|
||||
})
|
||||
|
||||
it("should handle empty headers correctly", () => {
|
||||
const items = ["@empty/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@empty": {
|
||||
url: "https://api.com/{name}.json",
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual(["https://api.com/button.json"])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should handle headers with environment variables that expand to empty", () => {
|
||||
process.env.EMPTY_TOKEN = "some-value"
|
||||
|
||||
const items = ["@empty/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@empty": {
|
||||
url: "https://api.com/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer ${EMPTY_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual(["https://api.com/button.json"])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({
|
||||
"https://api.com/button.json": {
|
||||
Authorization: "Bearer some-value",
|
||||
},
|
||||
})
|
||||
|
||||
delete process.env.EMPTY_TOKEN
|
||||
})
|
||||
|
||||
it("should handle headers with mixed static and environment variables", () => {
|
||||
process.env.API_TOKEN = "secret123"
|
||||
|
||||
const items = ["@mixed/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@mixed": {
|
||||
url: "https://api.com/{name}.json",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer ${API_TOKEN}",
|
||||
"X-Custom": "static-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual(["https://api.com/button.json"])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({
|
||||
"https://api.com/button.json": {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer secret123",
|
||||
"X-Custom": "static-value",
|
||||
},
|
||||
})
|
||||
|
||||
delete process.env.API_TOKEN
|
||||
})
|
||||
|
||||
it("should handle query parameters in URL config", () => {
|
||||
const items = ["@params/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@params": {
|
||||
url: "https://api.com/{name}.json",
|
||||
params: {
|
||||
version: "1.0",
|
||||
format: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://api.com/button.json?version=1.0&format=json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should handle query parameters with environment variables", () => {
|
||||
process.env.API_VERSION = "2.0"
|
||||
|
||||
const items = ["@params/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@params": {
|
||||
url: "https://api.com/{name}.json",
|
||||
params: {
|
||||
version: "${API_VERSION}",
|
||||
format: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://api.com/button.json?version=2.0&format=json",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
|
||||
delete process.env.API_VERSION
|
||||
})
|
||||
|
||||
it("should handle existing query parameters in URL", () => {
|
||||
const items = ["@existing/button"]
|
||||
const config = {
|
||||
registries: {
|
||||
"@existing": {
|
||||
url: "https://api.com/{name}.json?existing=true",
|
||||
params: {
|
||||
version: "1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = resolveRegistryItemsFromRegistries(items, config)
|
||||
|
||||
expect(result).toEqual([
|
||||
"https://api.com/button.json?existing=true&version=1.0",
|
||||
])
|
||||
expect(setRegistryHeaders).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
34
packages/shadcn/src/registry/resolver.ts
Normal file
34
packages/shadcn/src/registry/resolver.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { configSchema } from "@/src/registry/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
import { buildUrlAndHeadersForRegistryItem } from "./builder"
|
||||
import { setRegistryHeaders } from "./context"
|
||||
|
||||
export function resolveRegistryItemsFromRegistries(
|
||||
items: string[],
|
||||
config?: z.infer<typeof configSchema>
|
||||
) {
|
||||
const registryHeaders: Record<string, Record<string, string>> = {}
|
||||
const resolvedItems = [...items]
|
||||
|
||||
if (!config?.registries) {
|
||||
setRegistryHeaders({})
|
||||
return resolvedItems
|
||||
}
|
||||
|
||||
for (let i = 0; i < resolvedItems.length; i++) {
|
||||
const resolved = buildUrlAndHeadersForRegistryItem(resolvedItems[i], config)
|
||||
|
||||
if (resolved) {
|
||||
resolvedItems[i] = resolved.url
|
||||
|
||||
if (Object.keys(resolved.headers).length > 0) {
|
||||
registryHeaders[resolved.url] = resolved.headers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRegistryHeaders(registryHeaders)
|
||||
|
||||
return resolvedItems
|
||||
}
|
||||
49
packages/shadcn/src/registry/schema.test.ts
Normal file
49
packages/shadcn/src/registry/schema.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { registryConfigSchema } from "./schema"
|
||||
|
||||
describe("registryConfigSchema", () => {
|
||||
it("should accept valid registry names starting with @", () => {
|
||||
const validConfig = {
|
||||
"@v0": "https://v0.dev/{name}.json",
|
||||
"@acme": {
|
||||
url: "https://acme.com/{name}.json",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = registryConfigSchema.safeParse(validConfig)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should reject registry names not starting with @", () => {
|
||||
const invalidConfig = {
|
||||
v0: "https://v0.dev/{name}.json",
|
||||
acme: "https://acme.com/{name}.json",
|
||||
}
|
||||
|
||||
const result = registryConfigSchema.safeParse(invalidConfig)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain(
|
||||
"Registry names must start with @"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should reject URLs without {name} placeholder", () => {
|
||||
const invalidConfig = {
|
||||
"@v0": "https://v0.dev/component.json",
|
||||
}
|
||||
|
||||
const result = registryConfigSchema.safeParse(invalidConfig)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain(
|
||||
"Registry URL must include {name} placeholder"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -133,3 +133,67 @@ export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
|
||||
envVars: true,
|
||||
docs: true,
|
||||
})
|
||||
|
||||
export const registryConfigItemSchema = z.union([
|
||||
// Simple string format: "https://example.com/{name}.json"
|
||||
z.string().refine((s) => s.includes("{name}"), {
|
||||
message: "Registry URL must include {name} placeholder",
|
||||
}),
|
||||
// Advanced object format with auth options
|
||||
z.object({
|
||||
url: z.string().refine((s) => s.includes("{name}"), {
|
||||
message: "Registry URL must include {name} placeholder",
|
||||
}),
|
||||
params: z.record(z.string(), z.string()).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
export const registryConfigSchema = z.record(
|
||||
z.string().refine((key) => key.startsWith("@"), {
|
||||
message: "Registry names must start with @ (e.g., @v0, @acme)",
|
||||
}),
|
||||
registryConfigItemSchema
|
||||
)
|
||||
|
||||
export const rawConfigSchema = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
style: z.string(),
|
||||
rsc: z.coerce.boolean().default(false),
|
||||
tsx: z.coerce.boolean().default(true),
|
||||
tailwind: z.object({
|
||||
config: z.string().optional(),
|
||||
css: z.string(),
|
||||
baseColor: z.string(),
|
||||
cssVariables: z.boolean().default(true),
|
||||
prefix: z.string().default("").optional(),
|
||||
}),
|
||||
iconLibrary: z.string().optional(),
|
||||
aliases: z.object({
|
||||
components: z.string(),
|
||||
utils: z.string(),
|
||||
ui: z.string().optional(),
|
||||
lib: z.string().optional(),
|
||||
hooks: z.string().optional(),
|
||||
}),
|
||||
registries: registryConfigSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const configSchema = rawConfigSchema.extend({
|
||||
resolvedPaths: z.object({
|
||||
cwd: z.string(),
|
||||
tailwindConfig: z.string(),
|
||||
tailwindCss: z.string(),
|
||||
utils: z.string(),
|
||||
components: z.string(),
|
||||
lib: z.string(),
|
||||
hooks: z.string(),
|
||||
ui: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
// TODO: type the key.
|
||||
// Okay for now since I don't want a breaking change.
|
||||
export const workspaceConfigSchema = z.record(configSchema)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as fs from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import * as path from "path"
|
||||
import { registryItemSchema } from "@/src/registry"
|
||||
import { configSchema } from "@/src/utils/get-config"
|
||||
import { configSchema, registryItemSchema } from "@/src/registry"
|
||||
import { ProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { resolveImport } from "@/src/utils/resolve-import"
|
||||
import { Project, ScriptKind } from "ts-morph"
|
||||
@@ -273,7 +272,9 @@ export function isUniversalRegistryItem(
|
||||
return (
|
||||
!!registryItem?.files?.length &&
|
||||
registryItem.files.every(
|
||||
(file) => !!file.target && file.type === "registry:file"
|
||||
(file) =>
|
||||
!!file.target &&
|
||||
(file.type === "registry:file" || file.type === "registry:item")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
111
packages/shadcn/src/registry/validator.test.ts
Normal file
111
packages/shadcn/src/registry/validator.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
extractEnvVarsFromRegistryConfig,
|
||||
validateRegistryConfig,
|
||||
} from "./validator"
|
||||
|
||||
describe("extractEnvVarsFromRegistryConfig", () => {
|
||||
it("should extract vars from string config", () => {
|
||||
expect(
|
||||
extractEnvVarsFromRegistryConfig("https://api.com?token=${TOKEN}")
|
||||
).toEqual(["TOKEN"])
|
||||
})
|
||||
|
||||
it("should extract vars from object config", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}?key=${API_KEY}",
|
||||
params: {
|
||||
version: "1.0",
|
||||
token: "${TOKEN}",
|
||||
},
|
||||
headers: {
|
||||
Authorization: "Bearer ${AUTH_TOKEN}",
|
||||
"X-Api-Key": "${API_KEY}",
|
||||
},
|
||||
}
|
||||
|
||||
expect(extractEnvVarsFromRegistryConfig(config).sort()).toEqual([
|
||||
"API_KEY",
|
||||
"AUTH_TOKEN",
|
||||
"TOKEN",
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle config without params or headers", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
}
|
||||
|
||||
expect(extractEnvVarsFromRegistryConfig(config)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateRegistryConfig", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TOKEN = "value"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TOKEN
|
||||
})
|
||||
|
||||
describe("built-in registries", () => {
|
||||
it("should not throw for @shadcn since it's now a built-in registry", () => {
|
||||
expect(() => {
|
||||
validateRegistryConfig("@shadcn", {
|
||||
url: "https://example.com/{name}",
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should not throw for non-built-in registry names", () => {
|
||||
expect(() => {
|
||||
validateRegistryConfig("@mycompany", {
|
||||
url: "https://example.com/{name}",
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should not throw for similar but different registry names", () => {
|
||||
expect(() => {
|
||||
validateRegistryConfig("@shadcn-ui", {
|
||||
url: "https://example.com/{name}",
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => {
|
||||
validateRegistryConfig("@myshadcn", {
|
||||
url: "https://example.com/{name}",
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it("should pass when all env vars are set", () => {
|
||||
expect(() => {
|
||||
validateRegistryConfig("@test", "https://api.com?token=${TOKEN}")
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should throw when env vars are missing", () => {
|
||||
expect(() => {
|
||||
validateRegistryConfig("@test", "https://api.com?token=${MISSING}")
|
||||
}).toThrow(/Registry "@test" requires environment variables/)
|
||||
})
|
||||
|
||||
it("should list all missing variables", () => {
|
||||
const config = {
|
||||
url: "https://api.com/{name}",
|
||||
headers: {
|
||||
Auth: "${TOKEN1}",
|
||||
Key: "${TOKEN2}",
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
validateRegistryConfig("@test", config)
|
||||
}).toThrow(/TOKEN1[\s\S]*TOKEN2/)
|
||||
})
|
||||
})
|
||||
56
packages/shadcn/src/registry/validator.ts
Normal file
56
packages/shadcn/src/registry/validator.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { extractEnvVars } from "./env"
|
||||
import { registryConfigItemSchema } from "./schema"
|
||||
|
||||
export function extractEnvVarsFromRegistryConfig(
|
||||
config: z.infer<typeof registryConfigItemSchema>
|
||||
): string[] {
|
||||
const vars = new Set<string>()
|
||||
|
||||
if (typeof config === "string") {
|
||||
extractEnvVars(config).forEach((v) => vars.add(v))
|
||||
} else {
|
||||
extractEnvVars(config.url).forEach((v) => vars.add(v))
|
||||
|
||||
if (config.params) {
|
||||
Object.values(config.params).forEach((value) => {
|
||||
extractEnvVars(value).forEach((v) => vars.add(v))
|
||||
})
|
||||
}
|
||||
|
||||
if (config.headers) {
|
||||
Object.values(config.headers).forEach((value) => {
|
||||
extractEnvVars(value).forEach((v) => vars.add(v))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(vars)
|
||||
}
|
||||
|
||||
export function validateRegistryConfig(
|
||||
registryName: string,
|
||||
config: z.infer<typeof registryConfigItemSchema>
|
||||
): void {
|
||||
const requiredVars = extractEnvVarsFromRegistryConfig(config)
|
||||
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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
resolveRegistryItems,
|
||||
} from "@/src/registry/api"
|
||||
import {
|
||||
configSchema,
|
||||
registryItemFileSchema,
|
||||
registryItemSchema,
|
||||
workspaceConfigSchema,
|
||||
} from "@/src/registry/schema"
|
||||
import {
|
||||
configSchema,
|
||||
findCommonRoot,
|
||||
findPackageRoot,
|
||||
getWorkspaceConfig,
|
||||
workspaceConfigSchema,
|
||||
type Config,
|
||||
} from "@/src/utils/get-config"
|
||||
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
|
||||
@@ -40,6 +40,7 @@ export async function addComponents(
|
||||
silent?: boolean
|
||||
isNewProject?: boolean
|
||||
style?: string
|
||||
registryHeaders?: Record<string, Record<string, string>>
|
||||
}
|
||||
) {
|
||||
options = {
|
||||
@@ -341,7 +342,7 @@ async function shouldOverwriteCssVars(
|
||||
config: z.infer<typeof configSchema>
|
||||
) {
|
||||
let result = await Promise.all(
|
||||
components.map((component) => getRegistryItem(component, config.style))
|
||||
components.map((component) => getRegistryItem(component, config))
|
||||
)
|
||||
const payload = z.array(registryItemSchema).parse(result)
|
||||
|
||||
|
||||
28
packages/shadcn/src/utils/env-loader.ts
Normal file
28
packages/shadcn/src/utils/env-loader.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
|
||||
export async function loadEnvFiles(cwd: string = process.cwd()): Promise<void> {
|
||||
try {
|
||||
const { config } = await import("@dotenvx/dotenvx")
|
||||
const envFiles = [
|
||||
".env.local",
|
||||
".env.development.local",
|
||||
".env.development",
|
||||
".env",
|
||||
]
|
||||
|
||||
for (const envFile of envFiles) {
|
||||
const envPath = join(cwd, envFile)
|
||||
if (existsSync(envPath)) {
|
||||
config({
|
||||
path: envPath,
|
||||
overload: false,
|
||||
quiet: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load env files:", error)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import path from "path"
|
||||
import {
|
||||
configSchema,
|
||||
rawConfigSchema,
|
||||
workspaceConfigSchema,
|
||||
} from "@/src/registry"
|
||||
import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { resolveImport } from "@/src/utils/resolve-import"
|
||||
@@ -20,51 +26,8 @@ const explorer = cosmiconfig("components", {
|
||||
searchPlaces: ["components.json"],
|
||||
})
|
||||
|
||||
export const rawConfigSchema = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
style: z.string(),
|
||||
rsc: z.coerce.boolean().default(false),
|
||||
tsx: z.coerce.boolean().default(true),
|
||||
tailwind: z.object({
|
||||
config: z.string().optional(),
|
||||
css: z.string(),
|
||||
baseColor: z.string(),
|
||||
cssVariables: z.boolean().default(true),
|
||||
prefix: z.string().default("").optional(),
|
||||
}),
|
||||
aliases: z.object({
|
||||
components: z.string(),
|
||||
utils: z.string(),
|
||||
ui: z.string().optional(),
|
||||
lib: z.string().optional(),
|
||||
hooks: z.string().optional(),
|
||||
}),
|
||||
iconLibrary: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RawConfig = z.infer<typeof rawConfigSchema>
|
||||
|
||||
export const configSchema = rawConfigSchema.extend({
|
||||
resolvedPaths: z.object({
|
||||
cwd: z.string(),
|
||||
tailwindConfig: z.string(),
|
||||
tailwindCss: z.string(),
|
||||
utils: z.string(),
|
||||
components: z.string(),
|
||||
lib: z.string(),
|
||||
hooks: z.string(),
|
||||
ui: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export type Config = z.infer<typeof configSchema>
|
||||
|
||||
// TODO: type the key.
|
||||
// Okay for now since I don't want a breaking change.
|
||||
export const workspaceConfigSchema = z.record(configSchema)
|
||||
|
||||
export async function getConfig(cwd: string) {
|
||||
const config = await getRawConfig(cwd)
|
||||
|
||||
@@ -80,7 +43,16 @@ export async function getConfig(cwd: string) {
|
||||
return await resolveConfigPaths(cwd, config)
|
||||
}
|
||||
|
||||
export async function resolveConfigPaths(cwd: string, config: RawConfig) {
|
||||
export async function resolveConfigPaths(
|
||||
cwd: string,
|
||||
config: z.infer<typeof rawConfigSchema>
|
||||
) {
|
||||
// Merge built-in registries with user registries
|
||||
config.registries = {
|
||||
...BUILTIN_REGISTRIES,
|
||||
...(config.registries || {}),
|
||||
}
|
||||
|
||||
// Read tsconfig.json.
|
||||
const tsConfig = await loadConfig(cwd)
|
||||
|
||||
@@ -129,7 +101,9 @@ export async function resolveConfigPaths(cwd: string, config: RawConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRawConfig(cwd: string): Promise<RawConfig | null> {
|
||||
export async function getRawConfig(
|
||||
cwd: string
|
||||
): Promise<z.infer<typeof rawConfigSchema> | null> {
|
||||
try {
|
||||
const configResult = await explorer.search(cwd)
|
||||
|
||||
@@ -137,9 +111,25 @@ export async function getRawConfig(cwd: string): Promise<RawConfig | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
return rawConfigSchema.parse(configResult.config)
|
||||
const config = rawConfigSchema.parse(configResult.config)
|
||||
|
||||
// Check if user is trying to override built-in registries
|
||||
if (config.registries) {
|
||||
for (const registryName of Object.keys(config.registries)) {
|
||||
if (registryName in BUILTIN_REGISTRIES) {
|
||||
throw new Error(
|
||||
`"${registryName}" is a built-in registry and cannot be overridden.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
const componentPath = `${cwd}/components.json`
|
||||
if (error instanceof Error && error.message.includes("reserved registry")) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid configuration found in ${highlighter.info(componentPath)}.`
|
||||
)
|
||||
@@ -262,6 +252,9 @@ export function createConfig(partial?: DeepPartial<Config>): Config {
|
||||
components: "",
|
||||
utils: "",
|
||||
},
|
||||
registries: {
|
||||
...BUILTIN_REGISTRIES,
|
||||
},
|
||||
}
|
||||
|
||||
// Deep merge the partial config with defaults
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import path from "path"
|
||||
import { rawConfigSchema } from "@/src/registry"
|
||||
import { FRAMEWORKS, Framework } from "@/src/utils/frameworks"
|
||||
import {
|
||||
Config,
|
||||
RawConfig,
|
||||
getConfig,
|
||||
resolveConfigPaths,
|
||||
} from "@/src/utils/get-config"
|
||||
import { Config, getConfig, resolveConfigPaths } from "@/src/utils/get-config"
|
||||
import { getPackageInfo } from "@/src/utils/get-package-info"
|
||||
import fg from "fast-glob"
|
||||
import fs from "fs-extra"
|
||||
@@ -332,7 +328,7 @@ export async function getProjectConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
const config: RawConfig = {
|
||||
const config: z.infer<typeof rawConfigSchema> = {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
rsc: projectInfo.isRSC,
|
||||
tsx: projectInfo.isTsx,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from "@/src/utils/logger"
|
||||
import { z } from "zod"
|
||||
|
||||
export function handleError(error: unknown) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`Something went wrong. Please check the error below for more details.`
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function updateAppIndex(component: string, config: Config) {
|
||||
return
|
||||
}
|
||||
|
||||
const registryItem = await getRegistryItem(component, config.style)
|
||||
const registryItem = await getRegistryItem(component, config)
|
||||
if (
|
||||
!registryItem?.meta?.importSpecifier ||
|
||||
!registryItem?.meta?.moduleSpecifier
|
||||
|
||||
@@ -91,6 +91,9 @@ test("get config", async () => {
|
||||
lib: path.resolve(__dirname, "../fixtures/config-partial", "./lib"),
|
||||
},
|
||||
iconLibrary: "lucide",
|
||||
registries: {
|
||||
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -144,6 +147,9 @@ test("get config", async () => {
|
||||
"./src/lib/utils"
|
||||
),
|
||||
},
|
||||
registries: {
|
||||
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -185,6 +191,9 @@ test("get config", async () => {
|
||||
hooks: path.resolve(__dirname, "../fixtures/config-jsx", "./hooks"),
|
||||
lib: path.resolve(__dirname, "../fixtures/config-jsx", "./lib"),
|
||||
},
|
||||
registries: {
|
||||
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test } from "vitest"
|
||||
import { z } from "zod"
|
||||
|
||||
import { resolveTree } from "../../src/registry/api"
|
||||
import { Registry } from "../../src/registry/schema"
|
||||
import { registryItemSchema } from "../../src/registry/schema"
|
||||
|
||||
test("resolve tree", async () => {
|
||||
const index = [
|
||||
@@ -37,7 +38,7 @@ test("resolve tree", async () => {
|
||||
files: [{ type: "registry:component", path: "example-card.tsx" }],
|
||||
registryDependencies: ["button", "dialog", "input"],
|
||||
},
|
||||
] satisfies Registry
|
||||
] satisfies z.infer<typeof registryItemSchema>[]
|
||||
|
||||
expect(
|
||||
(await resolveTree(index, ["button"])).map((entry) => entry.name).sort()
|
||||
|
||||
BIN
packages/tests/fixtures/next-app-init/app/favicon.ico
vendored
Normal file
BIN
packages/tests/fixtures/next-app-init/app/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
120
packages/tests/fixtures/next-app-init/app/globals.css
vendored
Normal file
120
packages/tests/fixtures/next-app-init/app/globals.css
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
22
packages/tests/fixtures/next-app-init/app/layout.tsx
vendored
Normal file
22
packages/tests/fixtures/next-app-init/app/layout.tsx
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import "./globals.css"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
3
packages/tests/fixtures/next-app-init/app/other.css
vendored
Normal file
3
packages/tests/fixtures/next-app-init/app/other.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
background-color: red;
|
||||
}
|
||||
113
packages/tests/fixtures/next-app-init/app/page.tsx
vendored
Normal file
113
packages/tests/fixtures/next-app-init/app/page.tsx
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
import Image from "next/image"
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
||||
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
|
||||
Get started by editing
|
||||
<code className="font-mono font-bold">app/page.tsx</code>
|
||||
</p>
|
||||
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
|
||||
<a
|
||||
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
By{" "}
|
||||
<Image
|
||||
src="/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
className="dark:invert"
|
||||
width={100}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
|
||||
<Image
|
||||
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js Logo"
|
||||
width={180}
|
||||
height={37}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Docs{" "}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Find in-depth information about Next.js features and API.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Learn{" "}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Learn about Next.js in an interactive course with quizzes!
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Templates{" "}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Explore the Next.js 13 playground.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Deploy{" "}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Instantly deploy your Next.js site to a shareable URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
21
packages/tests/fixtures/next-app-init/components.json
vendored
Normal file
21
packages/tests/fixtures/next-app-init/components.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
6
packages/tests/fixtures/next-app-init/lib/utils.ts
vendored
Normal file
6
packages/tests/fixtures/next-app-init/lib/utils.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
4
packages/tests/fixtures/next-app-init/next.config.ts
vendored
Normal file
4
packages/tests/fixtures/next-app-init/next.config.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
6204
packages/tests/fixtures/next-app-init/package-lock.json
generated
vendored
Normal file
6204
packages/tests/fixtures/next-app-init/package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
32
packages/tests/fixtures/next-app-init/package.json
vendored
Normal file
32
packages/tests/fixtures/next-app-init/package.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.527.0",
|
||||
"next": "15.4.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
packages/tests/fixtures/next-app-init/postcss.config.mjs
vendored
Normal file
5
packages/tests/fixtures/next-app-init/postcss.config.mjs
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
27
packages/tests/fixtures/next-app-init/tsconfig.json
vendored
Normal file
27
packages/tests/fixtures/next-app-init/tsconfig.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
0
packages/tests/fixtures/no-framework/.gitkeep
vendored
Normal file
0
packages/tests/fixtures/no-framework/.gitkeep
vendored
Normal file
@@ -278,4 +278,19 @@ describe("shadcn add", () => {
|
||||
"
|
||||
`)
|
||||
})
|
||||
|
||||
it("should add registry:item with no framework", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("no-framework")
|
||||
await npxShadcn(fixturePath, [
|
||||
"add",
|
||||
"../../fixtures/registry/example-item.json",
|
||||
])
|
||||
|
||||
expect(await fs.pathExists(path.join(fixturePath, "path/to/foo.txt"))).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
await fs.readFile(path.join(fixturePath, "path/to/foo.txt"), "utf-8")
|
||||
).toBe("Foo Bar")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,9 @@ import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
|
||||
|
||||
describe("shadcn init - next-app", () => {
|
||||
it("should init with default configuration", async () => {
|
||||
// Sleep for 1 second to avoid race condition with the registry server.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
|
||||
|
||||
|
||||
1148
packages/tests/src/tests/registries.test.ts
Normal file
1148
packages/tests/src/tests/registries.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { execa } from "execa"
|
||||
import fs from "fs-extra"
|
||||
|
||||
import { TEMP_DIR } from "./setup"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.join(__dirname, "../../fixtures")
|
||||
const TEMP_DIR = path.join(__dirname, "../../temp")
|
||||
const SHADCN_CLI_PATH = path.join(__dirname, "../../../shadcn/dist/index.js")
|
||||
|
||||
export function getRegistryUrl() {
|
||||
@@ -14,12 +16,11 @@ export function getRegistryUrl() {
|
||||
|
||||
export async function createFixtureTestDirectory(fixtureName: string) {
|
||||
const fixturePath = path.join(FIXTURES_DIR, fixtureName)
|
||||
const testDir = path.join(
|
||||
TEMP_DIR,
|
||||
`test-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
)
|
||||
|
||||
await fs.ensureDir(TEMP_DIR)
|
||||
const uniqueId = `${process.pid}-${randomUUID().substring(0, 8)}`
|
||||
let testDir = path.join(TEMP_DIR, `test-${uniqueId}-${fixtureName}`)
|
||||
|
||||
await fs.ensureDir(testDir)
|
||||
await fs.copy(fixturePath, testDir)
|
||||
|
||||
return testDir
|
||||
|
||||
205
packages/tests/src/utils/registry.ts
Normal file
205
packages/tests/src/utils/registry.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createServer } from "http"
|
||||
import path from "path"
|
||||
import fs from "fs-extra"
|
||||
|
||||
export async function createRegistryServer(
|
||||
items: Array<{ name: string; type: string } & Record<string, unknown>>,
|
||||
{
|
||||
port = 4444,
|
||||
path = "/r",
|
||||
}: {
|
||||
port?: number
|
||||
path?: string
|
||||
}
|
||||
) {
|
||||
const server = createServer((request, response) => {
|
||||
const urlWithoutQuery = request.url?.split("?")[0]?.replace(/\.json$/, "")
|
||||
|
||||
if (urlWithoutQuery?.includes("icons/index")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
AlertCircle: {
|
||||
lucide: "AlertCircle",
|
||||
radix: "ExclamationTriangleIcon",
|
||||
},
|
||||
ArrowLeft: {
|
||||
lucide: "ArrowLeft",
|
||||
radix: "ArrowLeftIcon",
|
||||
},
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (urlWithoutQuery?.includes("colors/neutral")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
inlineColors: {
|
||||
light: {
|
||||
background: "white",
|
||||
foreground: "neutral-950",
|
||||
},
|
||||
dark: {
|
||||
background: "neutral-950",
|
||||
foreground: "neutral-50",
|
||||
},
|
||||
},
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "0 0% 100%",
|
||||
foreground: "0 0% 3.9%",
|
||||
},
|
||||
dark: {
|
||||
background: "0 0% 3.9%",
|
||||
foreground: "0 0% 98%",
|
||||
},
|
||||
},
|
||||
cssVarsV4: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.145 0 0)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
},
|
||||
},
|
||||
inlineColorsTemplate:
|
||||
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n ",
|
||||
cssVarsTemplate:
|
||||
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer ",
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (urlWithoutQuery?.includes("index")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
response.end(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: "alert-dialog",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/alert-dialog.tsx",
|
||||
type: "registry:ui",
|
||||
content:
|
||||
"export function AlertDialog() { return <div>AlertDialog Component from Registry Shadcn</div> }",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
content:
|
||||
"export function Button() { return <div>Button Component from Registry Shadcn</div> }",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const match = urlWithoutQuery?.match(
|
||||
new RegExp(`^${path}/(?:.*/)?([^/]+)$`)
|
||||
)
|
||||
const itemName = match?.[1]
|
||||
const item = itemName
|
||||
? items.find((item) => item.name === itemName)
|
||||
: undefined
|
||||
|
||||
if (!item) {
|
||||
response.writeHead(404, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify({ error: "Item not found" }))
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url?.includes("/bearer/")) {
|
||||
// Validate bearer token
|
||||
const token = request.headers.authorization?.split(" ")[1]
|
||||
if (token !== "EXAMPLE_BEARER_TOKEN") {
|
||||
response.writeHead(401, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify({ error: "Unauthorized" }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (request.url?.includes("/api-key/")) {
|
||||
// Validate api key
|
||||
const apiKey = request.headers["x-api-key"]
|
||||
if (apiKey !== "EXAMPLE_API_KEY") {
|
||||
response.writeHead(401, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify({ error: "Unauthorized" }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (request.url?.includes("/client-secret/")) {
|
||||
// Validate client secret
|
||||
const clientSecret = request.headers["x-client-secret"]
|
||||
const clientId = request.headers["x-client-id"]
|
||||
if (
|
||||
clientSecret !== "EXAMPLE_CLIENT_SECRET" ||
|
||||
clientId !== "EXAMPLE_CLIENT_ID"
|
||||
) {
|
||||
response.writeHead(401, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify({ error: "Unauthorized" }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (request.url?.includes("/params/")) {
|
||||
const token = request.url.split("?")[1]?.split("=")[1]
|
||||
if (token !== "EXAMPLE_REGISTRY_TOKEN") {
|
||||
response.writeHead(401, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify({ error: "Unauthorized" }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify(item))
|
||||
})
|
||||
|
||||
return {
|
||||
start: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(port, () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
stop: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureRegistries(
|
||||
fixturePath: string,
|
||||
payload: Record<string, any>
|
||||
) {
|
||||
if (!fs.pathExistsSync(path.join(fixturePath, "components.json"))) {
|
||||
await fs.writeJSON(path.join(fixturePath, "components.json"), {
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
const componentsJson = await fs.readJSON(
|
||||
path.join(fixturePath, "components.json")
|
||||
)
|
||||
componentsJson.registries = payload
|
||||
await fs.writeJSON(path.join(fixturePath, "components.json"), componentsJson)
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import fs from "fs-extra"
|
||||
import { rimraf } from "rimraf"
|
||||
|
||||
const TEMP_DIR = fs.mkdtempSync(path.join(tmpdir(), "shadcn-tests"))
|
||||
export const TEMP_DIR = path.join(__dirname, "../../temp")
|
||||
|
||||
console.log("TEMP_DIR", TEMP_DIR)
|
||||
|
||||
export async function setup() {
|
||||
await rimraf(TEMP_DIR)
|
||||
export default async function setup() {
|
||||
await fs.ensureDir(TEMP_DIR)
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
await rimraf(TEMP_DIR)
|
||||
return async () => {
|
||||
try {
|
||||
await rimraf(TEMP_DIR)
|
||||
} catch (error) {
|
||||
console.error("Failed to clean up temp directory:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
hookTimeout: 120000,
|
||||
globals: true,
|
||||
environment: "node",
|
||||
globalSetup: "./src/utils/setup.ts",
|
||||
globalSetup: ["./src/utils/setup.ts"],
|
||||
maxConcurrency: 4,
|
||||
isolate: false,
|
||||
},
|
||||
|
||||
120
pnpm-lock.yaml
generated
120
pnpm-lock.yaml
generated
@@ -713,6 +713,9 @@ importers:
|
||||
'@babel/plugin-transform-typescript':
|
||||
specifier: ^7.22.5
|
||||
version: 7.26.7(@babel/core@7.26.7)
|
||||
'@dotenvx/dotenvx':
|
||||
specifier: ^1.48.4
|
||||
version: 1.48.4
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.10.2
|
||||
version: 1.10.2
|
||||
@@ -1190,6 +1193,16 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dotenvx/dotenvx@1.48.4':
|
||||
resolution: {integrity: sha512-GpJWpGVI5JGhNzFlWOjCD3KMiN3xU1US4oLKQ7SiiGru4LvR7sUf3pDMpfjtlgzHStL5ydq4ekfZcRxWpHaJkA==}
|
||||
hasBin: true
|
||||
|
||||
'@ecies/ciphers@0.2.4':
|
||||
resolution: {integrity: sha512-t+iX+Wf5nRKyNzk8dviW3Ikb/280+aEJAnw9YXvCp2tYGPSkMki+NRY+8aNLmVFv3eNtMdvViPNOPxS8SZNP+w==}
|
||||
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
||||
peerDependencies:
|
||||
'@noble/ciphers': ^1.0.0
|
||||
|
||||
'@effect-ts/core@0.60.5':
|
||||
resolution: {integrity: sha512-qi1WrtJA90XLMnj2hnUszW9Sx4dXP03ZJtCc5DiUBIOhF4Vw7plfb65/bdBySPoC9s7zy995TdUX1XBSxUkl5w==}
|
||||
|
||||
@@ -2543,6 +2556,18 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/ciphers@1.3.0':
|
||||
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/curves@1.9.6':
|
||||
resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4983,6 +5008,10 @@ packages:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
commander@11.1.0:
|
||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -5366,6 +5395,10 @@ packages:
|
||||
resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.2.1:
|
||||
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@8.6.0:
|
||||
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5383,6 +5416,10 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
eciesjs@0.4.15:
|
||||
resolution: {integrity: sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==}
|
||||
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -6627,6 +6664,10 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isexe@3.1.1:
|
||||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
iterator.prototype@1.1.5:
|
||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7568,6 +7609,10 @@ packages:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
object-treeify@1.1.33:
|
||||
resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
object.assign@4.1.7:
|
||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -8625,6 +8670,7 @@ packages:
|
||||
source-map@0.8.0-beta.0:
|
||||
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
|
||||
engines: {node: '>= 8'}
|
||||
deprecated: The work that was done in this beta branch won't be included in future versions
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
@@ -9497,6 +9543,11 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
which@4.0.0:
|
||||
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
||||
engines: {node: ^16.13.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -10258,6 +10309,22 @@ snapshots:
|
||||
react: 19.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dotenvx/dotenvx@1.48.4':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
dotenv: 17.2.1
|
||||
eciesjs: 0.4.15
|
||||
execa: 5.1.1
|
||||
fdir: 6.4.6(picomatch@4.0.3)
|
||||
ignore: 5.3.1
|
||||
object-treeify: 1.1.33
|
||||
picomatch: 4.0.3
|
||||
which: 4.0.0
|
||||
|
||||
'@ecies/ciphers@0.2.4(@noble/ciphers@1.3.0)':
|
||||
dependencies:
|
||||
'@noble/ciphers': 1.3.0
|
||||
|
||||
'@effect-ts/core@0.60.5':
|
||||
dependencies:
|
||||
'@effect-ts/system': 0.57.5
|
||||
@@ -10299,7 +10366,7 @@ snapshots:
|
||||
'@esbuild-plugins/node-resolve@0.2.2(esbuild@0.25.0)':
|
||||
dependencies:
|
||||
'@types/resolve': 1.20.6
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
esbuild: 0.25.0
|
||||
escape-string-regexp: 4.0.0
|
||||
resolve: 1.22.10
|
||||
@@ -11287,6 +11354,14 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@15.3.1':
|
||||
optional: true
|
||||
|
||||
'@noble/ciphers@1.3.0': {}
|
||||
|
||||
'@noble/curves@1.9.6':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -14471,6 +14546,8 @@ snapshots:
|
||||
|
||||
commander@10.0.1: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
comment-json@4.2.5:
|
||||
@@ -14825,6 +14902,8 @@ snapshots:
|
||||
|
||||
dotenv@16.0.3: {}
|
||||
|
||||
dotenv@17.2.1: {}
|
||||
|
||||
dotenv@8.6.0: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -14839,6 +14918,13 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
eciesjs@0.4.15:
|
||||
dependencies:
|
||||
'@ecies/ciphers': 0.2.4(@noble/ciphers@1.3.0)
|
||||
'@noble/ciphers': 1.3.0
|
||||
'@noble/curves': 1.9.6
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.90: {}
|
||||
@@ -15719,7 +15805,7 @@ snapshots:
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
@@ -16059,7 +16145,7 @@ snapshots:
|
||||
dependencies:
|
||||
basic-ftp: 5.0.5
|
||||
data-uri-to-buffer: 6.0.2
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -16471,7 +16557,7 @@ snapshots:
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -16485,7 +16571,7 @@ snapshots:
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -16730,6 +16816,8 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.1: {}
|
||||
|
||||
iterator.prototype@1.1.5:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -17656,7 +17744,7 @@ snapshots:
|
||||
micromark@3.2.0:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
decode-named-character-reference: 1.0.2
|
||||
micromark-core-commonmark: 1.1.0
|
||||
micromark-factory-space: 1.1.0
|
||||
@@ -17678,7 +17766,7 @@ snapshots:
|
||||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
decode-named-character-reference: 1.1.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
@@ -18005,6 +18093,8 @@ snapshots:
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
|
||||
object-treeify@1.1.33: {}
|
||||
|
||||
object.assign@4.1.7:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -18146,7 +18236,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
get-uri: 6.0.4
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
@@ -18418,7 +18508,7 @@ snapshots:
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
@@ -19378,7 +19468,7 @@ snapshots:
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
socks: 2.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -20354,7 +20444,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@20.17.16)(lightningcss@1.30.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
es-module-lexer: 1.6.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.15(@types/node@20.17.16)(lightningcss@1.30.1)
|
||||
@@ -20371,7 +20461,7 @@ snapshots:
|
||||
|
||||
vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@5.4.15(@types/node@20.17.16)(lightningcss@1.30.1)):
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.4(typescript@5.7.3)
|
||||
optionalDependencies:
|
||||
@@ -20400,7 +20490,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.2.0
|
||||
debug: 4.4.0
|
||||
debug: 4.4.1
|
||||
expect-type: 1.2.0
|
||||
magic-string: 0.30.17
|
||||
pathe: 1.1.2
|
||||
@@ -20506,6 +20596,10 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
which@4.0.0:
|
||||
dependencies:
|
||||
isexe: 3.1.1
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
|
||||
@@ -2,3 +2,5 @@ packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "!**/test/**"
|
||||
- "!**/fixtures/**"
|
||||
- "!**/temp/**"
|
||||
|
||||
Reference in New Issue
Block a user