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:
shadcn
2025-08-04 14:35:41 +04:00
committed by GitHub
parent 0eccdc9c5f
commit 07eda36b13
55 changed files with 10375 additions and 253 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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()
}
})

View File

@@ -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)
}
})

View File

@@ -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,
})
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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)
})
})

View File

@@ -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)

View 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&param2=value2",
params: {
newParam: "newValue",
envParam: "${TEST_TOKEN}",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe(
"https://api.com/table?param1=value1&param2=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()
})
})

View 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
}

View File

@@ -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

View 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 = {}
}

View 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([])
})
})

View 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
}

View File

@@ -3,3 +3,5 @@ export {
registryResolveItemsTree as internal_registryResolveItemsTree,
fetchRegistry,
} from "./api"
export { BUILTIN_REGISTRIES, REGISTRY_URL } from "./constants"
export { buildUrlAndHeadersForRegistryItem } from "./builder"

View 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,
})
})
})
})

View 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,
}
}

View 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({})
})
})

View 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
}

View 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"
)
}
})
})

View File

@@ -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)

View File

@@ -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")
)
)
}

View 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/)
})
})

View 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."
)
}
}

View File

@@ -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)

View 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)
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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.`
)

View File

@@ -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

View File

@@ -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",
},
})
})

View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
body {
background-color: red;
}

View 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&nbsp;
<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">
-&gt;
</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">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Learn about Next.js in an interactive course with&nbsp;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">
-&gt;
</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">
-&gt;
</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>
)
}

View 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"
}

View 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))
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View 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"]
}

View File

View 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")
})
})

View File

@@ -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"])

File diff suppressed because it is too large Load Diff

View File

@@ -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

View 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)
}

View File

@@ -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)
}
}
}

View File

@@ -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
View File

@@ -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

View File

@@ -2,3 +2,5 @@ packages:
- "apps/*"
- "packages/*"
- "!**/test/**"
- "!**/fixtures/**"
- "!**/temp/**"