feat(shadcn): update registry dependencies resolution algorithm (#7948)

* feat(shadcn): update dependency resolution algorithm

* feat(shadcn): rename style to base-style

* feat(shadcn): init from namespaced

* fix(shadcn): force validation early

* chore: changeset

* fix(shadcn): headers

* fix: smh

* fix(shadcn): restore backup on exit and error
This commit is contained in:
shadcn
2025-08-06 13:38:08 +04:00
committed by GitHub
parent 578f83cbef
commit 2c164b0f22
16 changed files with 1514 additions and 201 deletions

View File

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

View File

@@ -170,7 +170,7 @@ export const add = new Command()
isNewProject: false,
srcDir: options.srcDir,
cssVariables: options.cssVariables,
style: "index",
baseStyle: true,
})
}
@@ -202,7 +202,7 @@ export const add = new Command()
isNewProject: true,
srcDir: options.srcDir,
cssVariables: options.cssVariables,
style: "index",
baseStyle: true,
})
shouldUpdateAppIndex =

View File

@@ -1,6 +1,7 @@
import { promises as fs } from "fs"
import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry"
import {
BASE_COLORS,
getRegistryBaseColors,
@@ -9,11 +10,16 @@ import {
} 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 {
FILE_BACKUP_SUFFIX,
createFileBackup,
deleteFileBackup,
restoreFileBackup,
} from "@/src/utils/file-helper"
import {
DEFAULT_COMPONENTS,
DEFAULT_TAILWIND_CONFIG,
@@ -34,9 +40,23 @@ import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { updateTailwindContent } from "@/src/utils/updaters/update-tailwind-content"
import { Command } from "commander"
import deepmerge from "deepmerge"
import fsExtra from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
process.on("exit", (code) => {
const filePath = path.resolve(process.cwd(), "components.json")
// Delete backup if successful.
if (code === 0) {
return deleteFileBackup(filePath)
}
// Restore backup if error.
return restoreFileBackup(filePath)
})
export const initOptionsSchema = z.object({
cwd: z.string(),
components: z.array(z.string()).optional(),
@@ -78,7 +98,7 @@ export const initOptionsSchema = z.object({
).join("', '")}'`,
}
),
style: z.string(),
baseStyle: z.boolean(),
})
export const init = new Command()
@@ -114,32 +134,67 @@ export const init = new Command()
)
.option("--css-variables", "use css variables for theming.", true)
.option("--no-css-variables", "do not use css variables for theming.")
.option("--no-base-style", "do not install the base shadcn style.")
.action(async (components, opts) => {
try {
const options = initOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
isNewProject: false,
components,
style: "index",
...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.
// This will allow us to determine if we need to install the base style.
// And if we should prompt the user for a base color.
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.
if (item?.type === "registry:style") {
options.baseColor = "neutral"
options.style = item.extends ?? "index"
// We don't know the full config at this point.
// So we'll use a shadow config to fetch the first item.
let shadowConfig: Parameters<typeof getRegistryItem>[1] = {
style: "new-york",
resolvedPaths: {
cwd: "",
},
}
// Check if there's a components.json file.
// If so, we'll merge with our shadow config.
const componentsJsonPath = path.resolve(options.cwd, "components.json")
if (fsExtra.existsSync(componentsJsonPath)) {
const existingConfig = await fsExtra.readJson(componentsJsonPath)
const config = rawConfigSchema.partial().parse(existingConfig)
shadowConfig = {
...shadowConfig,
...config,
}
// Since components.json might not be valid at this point.
// Temporarily rename components.json to allow preflight to run.
// We'll rename it back after preflight.
createFileBackup(componentsJsonPath)
}
// This forces a shadowConfig validation early in the process.
buildUrlAndHeadersForRegistryItem(components[0], shadowConfig)
const item = await getRegistryItem(components[0], shadowConfig)
if (item?.type === "registry:style") {
// Set a default base color so we're not prompted.
// The style will extend or override it.
options.baseColor = "neutral"
// If the style extends none, we don't want to install the base style.
options.baseStyle =
item.extends === "none" ? false : options.baseStyle
}
}
// If --no-base-style, we don't want to prompt for a base color either.
// The style will extend or override it.
if (!options.baseStyle) {
options.baseColor = "neutral"
}
await runInit(options)
@@ -187,7 +242,8 @@ export async function runInit(
}
const projectConfig = await getProjectConfig(options.cwd, projectInfo)
const config = projectConfig
let config = projectConfig
? await promptForMinimalConfig(projectConfig, options)
: await promptForConfig(await getConfig(options.cwd))
@@ -206,23 +262,38 @@ export async function runInit(
}
}
// Write components.json.
const componentSpinner = spinner(`Writing components.json.`).start()
const targetPath = path.resolve(options.cwd, "components.json")
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), "utf8")
const backupPath = `${targetPath}${FILE_BACKUP_SUFFIX}`
// Merge with backup config if it exists and not using --force
if (!options.force && fsExtra.existsSync(backupPath)) {
const existingConfig = await fsExtra.readJson(backupPath)
// Move registries at the end of the config.
const { registries, ...merged } = deepmerge(existingConfig, config)
config = { ...merged, registries }
}
// Write components.json.
await fs.writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf8")
componentSpinner.succeed()
// Add components.
const fullConfig = await resolveConfigPaths(options.cwd, config)
const components = [
...(options.style === "none" ? [] : [options.style]),
// "index" is the default shadcn style.
// Why index? Because when style is true, we read style from components.json and fetch that.
// i.e new-york from components.json then fetch /styles/new-york/index.
// TODO: Fix this so that we can extend any style i.e --style=new-york.
...(options.baseStyle ? ["index"] : []),
...(options.components ?? []),
]
await addComponents(components, fullConfig, {
// Init will always overwrite files.
overwrite: true,
silent: options.silent,
style: options.style,
baseStyle: options.baseStyle,
isNewProject:
options.isNewProject || projectInfo?.framework.name === "next-app",
})

View File

@@ -1,3 +1,4 @@
import { createHash } from "crypto"
import { promises as fs } from "fs"
import { homedir } from "os"
import path from "path"
@@ -9,11 +10,7 @@ import {
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
import { resolveRegistryItemsFromRegistries } from "@/src/registry/resolver"
import { isLocalFile } from "@/src/registry/utils"
import {
Config,
createConfig,
getTargetStyleFromConfig,
} from "@/src/utils/get-config"
import { Config, 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"
@@ -33,7 +30,6 @@ import {
registryResolvedItemsTreeSchema,
registrySchema,
stylesSchema,
type RegistryItem,
} from "./schema"
const agent = process.env.https_proxy
@@ -100,8 +96,16 @@ export async function getRegistryIcons() {
export async function getRegistryItem(
name: string,
config?: Config
): Promise<RegistryItem | null> {
config?: Parameters<typeof fetchFromRegistry>[1],
options?: {
useCache?: boolean
}
) {
options = {
useCache: true,
...options,
}
try {
if (isLocalFile(name)) {
return await getLocalRegistryItem(name)
@@ -113,13 +117,14 @@ export async function getRegistryItem(
}
if (name.startsWith("@") && config?.registries) {
const [result] = await fetchFromRegistry([name], config)
const [result] = await fetchFromRegistry([name], config, options)
return result
}
// Handle regular component names
const path = `styles/${config?.style ?? "new-york-v4"}/${name}.json`
const [result] = await fetchFromRegistry([path], config)
// Handles regular component names.
const resolvedStyle = await getResolvedStyle(config)
const path = `styles/${resolvedStyle ?? "new-york-v4"}/${name}.json`
const [result] = await fetchFromRegistry([path], config, options)
return result
} catch (error) {
logger.break()
@@ -148,7 +153,10 @@ async function getLocalRegistryItem(filePath: string) {
}
}
export async function getRegistry(name: `${string}/registry`, config?: Config) {
export async function getRegistry(
name: `${string}/registry`,
config?: Parameters<typeof fetchFromRegistry>[1]
) {
try {
const results = await fetchFromRegistry([name], config, { useCache: false })
@@ -218,6 +226,9 @@ export async function fetchTree(
}
}
/**
* @deprecated This function is deprecated and will be removed in a future version.
*/
export async function getItemTargetPath(
config: Config,
item: Pick<z.infer<typeof registryItemSchema>, "type">,
@@ -356,7 +367,11 @@ export function clearRegistryCache() {
registryCache.clear()
}
async function getResolvedStyle(config?: Config) {
async function getResolvedStyle(
config?: Pick<Config, "style"> & {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
}
) {
if (!config) {
return undefined
}
@@ -369,19 +384,25 @@ async function getResolvedStyle(config?: Config) {
export async function fetchFromRegistry(
items: `${string}/registry`[],
config?: Config,
config?: Pick<Config, "style" | "registries"> & {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
},
options?: { useCache?: boolean }
): Promise<z.infer<typeof registrySchema>[]>
export async function fetchFromRegistry(
items: string[],
config?: Config,
config?: Pick<Config, "style" | "registries"> & {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
},
options?: { useCache?: boolean }
): Promise<z.infer<typeof registryItemSchema>[]>
export async function fetchFromRegistry(
items: string[],
config?: Config,
config?: Pick<Config, "style" | "registries"> & {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
},
options: { useCache?: boolean } = {}
): Promise<
(z.infer<typeof registrySchema> | z.infer<typeof registryItemSchema>)[]
@@ -414,7 +435,7 @@ export async function fetchFromRegistry(
async function resolveDependenciesRecursively(
dependencies: string[],
config?: Config,
config?: Pick<Config, "style" | "registries" | "resolvedPaths">,
visited: Set<string> = new Set()
) {
const items: z.infer<typeof registryItemSchema>[] = []
@@ -520,20 +541,29 @@ export async function registryResolveItemsTree(
(name) => !isLocalFile(name) && !isUrl(name)
)
const payload: z.infer<typeof registryItemSchema>[] = []
let payload: z.infer<typeof registryItemWithSourceSchema>[] = []
// Handle local files and URLs directly, resolving their dependencies individually.
let allDependencyItems: z.infer<typeof registryItemSchema>[] = []
let allDependencyItems: z.infer<typeof registryItemWithSourceSchema>[] = []
let allDependencyRegistryNames: string[] = []
const resolvedStyle = await getResolvedStyle(config)
const configWithStyle =
config && resolvedStyle ? { ...config, style: resolvedStyle } : config
for (const localFile of localFiles) {
// Deduplicate exact URLs/paths before fetching
const uniqueLocalFiles = Array.from(new Set(localFiles))
const uniqueUrls = Array.from(new Set(urls))
for (const localFile of uniqueLocalFiles) {
const item = await getRegistryItem(localFile)
if (item) {
payload.push(item)
// Add source tracking
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
...item,
_source: localFile,
}
payload.push(itemWithSource)
if (item.registryDependencies) {
// Resolve namespace syntax and set headers for dependencies
let resolvedDependencies = item.registryDependencies
@@ -570,10 +600,15 @@ export async function registryResolveItemsTree(
}
}
for (const url of urls) {
for (const url of uniqueUrls) {
const item = await getRegistryItem(url, config)
if (item) {
payload.push(item)
// Add source tracking
const itemWithSource: z.infer<typeof registryItemWithSourceSchema> = {
...item,
_source: url,
}
payload.push(itemWithSource)
if (item.registryDependencies) {
// Resolve namespace syntax and set headers for dependencies
let resolvedDependencies = item.registryDependencies
@@ -630,7 +665,15 @@ export async function registryResolveItemsTree(
const namespacedPayload = results as z.infer<
typeof registryItemSchema
>[]
payload.push(...namespacedPayload)
// Add source tracking for namespaced items
const itemsWithSource: z.infer<typeof registryItemWithSourceSchema>[] =
namespacedPayload.map((item, index) => ({
...item,
_source: namespacedItems[index],
}))
payload.push(...itemsWithSource)
// Process dependencies of namespaced items
for (const item of namespacedPayload) {
@@ -684,6 +727,8 @@ export async function registryResolveItemsTree(
return null
}
// No deduplication - we want to support multiple items with the same name from different sources
// If we're resolving the index, we want to fetch
// the theme item if a base color is provided.
// We do this for index only.
@@ -697,6 +742,17 @@ export async function registryResolveItemsTree(
}
}
// Build source map for topological sort
const sourceMap = new Map<z.infer<typeof registryItemSchema>, string>()
payload.forEach((item) => {
// Use the _source property if it was added, otherwise use the name
const source = item._source || item.name
sourceMap.set(item, source)
})
// Apply topological sort to ensure dependencies come before dependents
payload = topologicalSortRegistryItems(payload, sourceMap)
// Sort the payload so that registry:theme items come first,
// while maintaining the relative order of all items.
payload.sort((a, b) => {
@@ -945,3 +1001,168 @@ export function getRegistryParentMap(
})
return map
}
const registryItemWithSourceSchema = registryItemSchema.extend({
_source: z.string().optional(),
})
function computeItemHash(
item: Pick<z.infer<typeof registryItemSchema>, "name">,
source?: string
) {
const identifier = source || item.name
const hash = createHash("sha256")
.update(identifier)
.digest("hex")
.substring(0, 8)
return `${item.name}::${hash}`
}
function extractItemIdentifierFromDependency(dependency: string) {
if (isUrl(dependency)) {
const url = new URL(dependency)
const pathname = url.pathname
const match = pathname.match(/\/([^/]+)\.json$/)
const name = match ? match[1] : path.basename(pathname, ".json")
return {
name,
hash: computeItemHash({ name }, dependency),
}
}
if (isLocalFile(dependency)) {
const match = dependency.match(/\/([^/]+)\.json$/)
const name = match ? match[1] : path.basename(dependency, ".json")
return {
name,
hash: computeItemHash({ name }, dependency),
}
}
const { item } = parseRegistryAndItemFromString(dependency)
return {
name: item,
hash: computeItemHash({ name: item }, dependency),
}
}
function topologicalSortRegistryItems(
items: z.infer<typeof registryItemSchema>[],
sourceMap: Map<z.infer<typeof registryItemSchema>, string>
) {
const itemMap = new Map<string, z.infer<typeof registryItemSchema>>()
const hashToItem = new Map<string, z.infer<typeof registryItemSchema>>()
const inDegree = new Map<string, number>()
const adjacencyList = new Map<string, string[]>()
items.forEach((item) => {
const source = sourceMap.get(item) || item.name
const hash = computeItemHash(item, source)
itemMap.set(hash, item)
hashToItem.set(hash, item)
inDegree.set(hash, 0)
adjacencyList.set(hash, [])
})
// Build a map of dependency to possible items.
const depToHashes = new Map<string, string[]>()
items.forEach((item) => {
const source = sourceMap.get(item) || item.name
const hash = computeItemHash(item, source)
if (!depToHashes.has(item.name)) {
depToHashes.set(item.name, [])
}
depToHashes.get(item.name)!.push(hash)
if (source !== item.name) {
if (!depToHashes.has(source)) {
depToHashes.set(source, [])
}
depToHashes.get(source)!.push(hash)
}
})
items.forEach((item) => {
const itemSource = sourceMap.get(item) || item.name
const itemHash = computeItemHash(item, itemSource)
if (item.registryDependencies) {
item.registryDependencies.forEach((dep) => {
let depHash: string | undefined
const exactMatches = depToHashes.get(dep) || []
if (exactMatches.length === 1) {
depHash = exactMatches[0]
} else if (exactMatches.length > 1) {
// Multiple matches - try to disambiguate.
// For now, just use the first one and warn.
depHash = exactMatches[0]
} else {
const { name } = extractItemIdentifierFromDependency(dep)
const nameMatches = depToHashes.get(name) || []
if (nameMatches.length > 0) {
depHash = nameMatches[0]
}
}
if (depHash && itemMap.has(depHash)) {
adjacencyList.get(depHash)!.push(itemHash)
inDegree.set(itemHash, inDegree.get(itemHash)! + 1)
}
})
}
})
// Implements Kahn's algorithm.
const queue: string[] = []
const sorted: z.infer<typeof registryItemSchema>[] = []
inDegree.forEach((degree, hash) => {
if (degree === 0) {
queue.push(hash)
}
})
while (queue.length > 0) {
const currentHash = queue.shift()!
const item = itemMap.get(currentHash)!
sorted.push(item)
adjacencyList.get(currentHash)!.forEach((dependentHash) => {
const newDegree = inDegree.get(dependentHash)! - 1
inDegree.set(dependentHash, newDegree)
if (newDegree === 0) {
queue.push(dependentHash)
}
})
}
if (sorted.length !== items.length) {
console.warn("Circular dependency detected in registry items")
// Return all items even if there are circular dependencies
// Items not in sorted are part of circular dependencies
const sortedHashes = new Set(
sorted.map((item) => {
const source = sourceMap.get(item) || item.name
return computeItemHash(item, source)
})
)
items.forEach((item) => {
const source = sourceMap.get(item) || item.name
const hash = computeItemHash(item, source)
if (!sortedHashes.has(hash)) {
sorted.push(item)
}
})
}
return sorted
}

View File

@@ -13,7 +13,7 @@ const QUERY_PARAM_DELIMITER = "&"
export function buildUrlAndHeadersForRegistryItem(
name: string,
config?: z.infer<typeof configSchema>
config?: Pick<z.infer<typeof configSchema>, "registries" | "style">
) {
const { registry, item } = parseRegistryAndItemFromString(name)
@@ -43,7 +43,7 @@ export function buildUrlAndHeadersForRegistryItem(
export function buildUrlFromRegistryConfig(
item: string,
registryConfig: z.infer<typeof registryConfigItemSchema>,
config?: z.infer<typeof configSchema>
config?: Pick<z.infer<typeof configSchema>, "style">
) {
if (typeof registryConfig === "string") {
let url = registryConfig.replace(NAME_PLACEHOLDER, item)

View File

@@ -6,7 +6,7 @@ import { setRegistryHeaders } from "./context"
export function resolveRegistryItemsFromRegistries(
items: string[],
config?: z.infer<typeof configSchema>
config?: Pick<z.infer<typeof configSchema>, "registries" | "style">
) {
const registryHeaders: Record<string, Record<string, string>> = {}
const resolvedItems = [...items]

View File

@@ -39,7 +39,7 @@ export async function addComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
style?: string
baseStyle?: boolean
registryHeaders?: Record<string, Record<string, string>>
}
) {
@@ -47,7 +47,7 @@ export async function addComponents(
overwrite: false,
silent: false,
isNewProject: false,
style: "index",
baseStyle: true,
...options,
}
@@ -74,9 +74,13 @@ async function addProjectComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
style?: string
baseStyle?: boolean
}
) {
if (!options.baseStyle && !components.length) {
return
}
const registrySpinner = spinner(`Checking registry.`, {
silent: options.silent,
})?.start()
@@ -110,7 +114,7 @@ async function addProjectComponents(
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
overwriteCssVars,
initIndex: options.style ? options.style === "index" : false,
initIndex: options.baseStyle,
})
// Add CSS updater
@@ -144,9 +148,13 @@ async function addWorkspaceComponents(
silent?: boolean
isNewProject?: boolean
isRemote?: boolean
style?: string
baseStyle?: boolean
}
) {
if (!options.baseStyle && !components.length) {
return
}
const registrySpinner = spinner(`Checking registry.`, {
silent: options.silent,
})?.start()

View File

@@ -0,0 +1,52 @@
import fsExtra from "fs-extra"
export const FILE_BACKUP_SUFFIX = ".bak"
export function createFileBackup(filePath: string): string | null {
if (!fsExtra.existsSync(filePath)) {
return null
}
const backupPath = `${filePath}${FILE_BACKUP_SUFFIX}`
try {
fsExtra.renameSync(filePath, backupPath)
return backupPath
} catch (error) {
console.error(`Failed to create backup of ${filePath}: ${error}`)
return null
}
}
export function restoreFileBackup(filePath: string): boolean {
const backupPath = `${filePath}${FILE_BACKUP_SUFFIX}`
if (!fsExtra.existsSync(backupPath)) {
return false
}
try {
fsExtra.renameSync(backupPath, filePath)
return true
} catch (error) {
console.error(
`Warning: Could not restore backup file ${backupPath}: ${error}`
)
return false
}
}
export function deleteFileBackup(filePath: string): boolean {
const backupPath = `${filePath}${FILE_BACKUP_SUFFIX}`
if (!fsExtra.existsSync(backupPath)) {
return false
}
try {
fsExtra.unlinkSync(backupPath)
return true
} catch (error) {
// Best effort - don't log as this is just cleanup
return false
}
}

View File

@@ -353,9 +353,9 @@ export async function getProjectConfig(
return await resolveConfigPaths(cwd, config)
}
export async function getProjectTailwindVersionFromConfig(
config: Config
): Promise<TailwindVersion> {
export async function getProjectTailwindVersionFromConfig(config: {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
}): Promise<TailwindVersion> {
if (!config.resolvedPaths?.cwd) {
return "v3"
}

View File

@@ -11,12 +11,12 @@ exports[`registryResolveItemTree > should resolve index 1`] = `
"theme": {},
},
"dependencies": [
"tailwindcss-animate",
"class-variance-authority",
"lucide-react",
"clsx",
"tailwind-merge",
"@radix-ui/react-label",
"tailwindcss-animate",
"class-variance-authority",
"lucide-react",
],
"devDependencies": [],
"docs": "",
@@ -170,8 +170,8 @@ exports[`registryResolveItemTree > should resolve multiple items tree 1`] = `
"cssVars": {},
"dependencies": [
"@radix-ui/react-slot",
"cmdk",
"@radix-ui/react-dialog",
"cmdk",
],
"devDependencies": [],
"docs": "",
@@ -269,6 +269,134 @@ export { Input }
{
"content": ""use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
",
"path": "ui/dialog.tsx",
"target": "",
"type": "registry:ui",
},
{
"content": ""use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
@@ -425,134 +553,6 @@ export {
"target": "",
"type": "registry:ui",
},
{
"content": ""use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
",
"path": "ui/dialog.tsx",
"target": "",
"type": "registry:ui",
},
],
"tailwind": {},
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest"
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"
import { createRegistryServer } from "../../../../tests/src/utils/registry"
import { registryResolveItemsTree } from "../../../src/registry/api"
describe("registryResolveItemTree", () => {
@@ -36,3 +37,316 @@ describe("registryResolveItemTree", () => {
).toMatchSnapshot()
})
})
describe("registryResolveItemTree - dependency ordering", () => {
let customRegistry: Awaited<ReturnType<typeof createRegistryServer>>
beforeAll(async () => {
// Create a custom registry server for testing dependency ordering
customRegistry = await createRegistryServer(
[
{
name: "base-component",
type: "registry:ui",
files: [
{
path: "components/ui/base.tsx",
content: "export const Base = () => <div>Base Component</div>",
type: "registry:ui",
},
],
cssVars: {
light: { base: "#111111" },
},
},
{
name: "extended-component",
type: "registry:ui",
registryDependencies: ["http://localhost:4447/r/base-component.json"],
files: [
{
path: "components/ui/extended.tsx",
content:
"export const Extended = () => <div>Extended Component</div>",
type: "registry:ui",
},
],
cssVars: {
light: { extended: "#222222" },
},
},
{
name: "deep-component",
type: "registry:ui",
registryDependencies: [
"http://localhost:4447/r/extended-component.json",
],
files: [
{
path: "components/ui/deep.tsx",
content: "export const Deep = () => <div>Deep Component</div>",
type: "registry:ui",
},
],
},
// Circular dependency test
{
name: "circular-a",
type: "registry:ui",
registryDependencies: ["http://localhost:4447/r/circular-b.json"],
files: [
{
path: "components/ui/circular-a.tsx",
content: "export const CircularA = () => <div>A</div>",
type: "registry:ui",
},
],
},
{
name: "circular-b",
type: "registry:ui",
registryDependencies: ["http://localhost:4447/r/circular-a.json"],
files: [
{
path: "components/ui/circular-b.tsx",
content: "export const CircularB = () => <div>B</div>",
type: "registry:ui",
},
],
},
],
{ port: 4447 }
)
await customRegistry.start()
})
afterAll(async () => {
await customRegistry.stop()
})
test("should order dependencies before items that depend on them", async () => {
const result = await registryResolveItemsTree(
["http://localhost:4447/r/extended-component.json"],
{
style: "default",
tailwind: { baseColor: "neutral" },
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "./tailwind.config.js",
tailwindCss: "./globals.css",
utils: "./lib/utils",
components: "./components",
lib: "./lib",
hooks: "./hooks",
ui: "./components/ui",
},
}
)
expect(result).toBeTruthy()
expect(result?.files).toHaveLength(2)
// Base component should come first.
expect(result?.files?.[0]).toMatchObject({
path: "components/ui/base.tsx",
content: expect.stringContaining("Base Component"),
})
// Extended component should come second.
expect(result?.files?.[1]).toMatchObject({
path: "components/ui/extended.tsx",
content: expect.stringContaining("Extended Component"),
})
expect(result?.cssVars?.light).toMatchObject({
base: "#111111",
extended: "#222222",
})
})
test("should handle complex dependency chains", async () => {
const result = await registryResolveItemsTree(
["http://localhost:4447/r/deep-component.json"],
{
style: "new-york",
tailwind: { baseColor: "neutral" },
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "./tailwind.config.js",
tailwindCss: "./globals.css",
utils: "./lib/utils",
components: "./components",
lib: "./lib",
hooks: "./hooks",
ui: "./components/ui",
},
}
)
expect(result).toBeTruthy()
expect(result?.files).toHaveLength(3)
// Order should be: base -> extended -> deep.
expect(result?.files?.[0].content).toContain("Base Component")
expect(result?.files?.[1].content).toContain("Extended Component")
expect(result?.files?.[2].content).toContain("Deep Component")
})
test("should handle circular dependencies gracefully", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const result = await registryResolveItemsTree(
[
"http://localhost:4447/r/circular-a.json",
"http://localhost:4447/r/circular-b.json",
],
{
style: "new-york",
tailwind: { baseColor: "neutral" },
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "./tailwind.config.js",
tailwindCss: "./globals.css",
utils: "./lib/utils",
components: "./components",
lib: "./lib",
hooks: "./hooks",
ui: "./components/ui",
},
}
)
expect(result).toBeTruthy()
// With circular dependencies, we might get duplicates in the files array
// but we should have at least one of each circular item
const hasCircularA = result?.files?.some(
(f) => f.path === "components/ui/circular-a.tsx"
)
const hasCircularB = result?.files?.some(
(f) => f.path === "components/ui/circular-b.tsx"
)
expect(hasCircularA).toBe(true)
expect(hasCircularB).toBe(true)
// Should have logged a warning about circular dependency
expect(consoleSpy).toHaveBeenCalledWith(
"Circular dependency detected in registry items"
)
consoleSpy.mockRestore()
})
test("should handle exact duplicate URLs by including only once", async () => {
const result = await registryResolveItemsTree(
[
"http://localhost:4447/r/base-component.json",
"http://localhost:4447/r/base-component.json",
],
{
style: "new-york",
tailwind: { baseColor: "neutral" },
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "./tailwind.config.js",
tailwindCss: "./globals.css",
utils: "./lib/utils",
components: "./components",
lib: "./lib",
hooks: "./hooks",
ui: "./components/ui",
},
}
)
expect(result?.files).toHaveLength(1)
expect(result?.files?.[0].path).toBe("components/ui/base.tsx")
})
test("should handle items with same name from different registries", async () => {
const secondRegistry = await createRegistryServer(
[
{
name: "base-component",
type: "registry:ui",
files: [
{
path: "components/ui/base-alt.tsx",
content:
"export const BaseAlt = () => <div>Alternative Base Component</div>",
type: "registry:ui",
},
],
cssVars: {
light: { altBase: "#999999" },
},
},
],
{ port: 4448 }
)
await secondRegistry.start()
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const result = await registryResolveItemsTree(
[
"http://localhost:4447/r/base-component.json",
"http://localhost:4448/r/base-component.json",
],
{
style: "default",
tailwind: { baseColor: "neutral" },
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "./tailwind.config.js",
tailwindCss: "./globals.css",
utils: "./lib/utils",
components: "./components",
lib: "./lib",
hooks: "./hooks",
ui: "./components/ui",
},
}
)
expect(result?.files).toHaveLength(2)
const filePaths = result?.files?.map((f) => f.path).sort()
expect(filePaths).toEqual([
"components/ui/base-alt.tsx",
"components/ui/base.tsx",
])
expect(result?.cssVars?.light).toHaveProperty("base", "#111111")
expect(result?.cssVars?.light).toHaveProperty("altBase", "#999999")
consoleSpy.mockRestore()
await secondRegistry.stop()
})
test("should correctly resolve dependencies when multiple items have same dependency name", async () => {
const result = await registryResolveItemsTree(
["http://localhost:4447/r/extended-component.json"],
{
style: "new-york",
tailwind: { baseColor: "neutral" },
resolvedPaths: {
cwd: process.cwd(),
tailwindConfig: "./tailwind.config.js",
tailwindCss: "./globals.css",
utils: "./lib/utils",
components: "./components",
lib: "./lib",
hooks: "./hooks",
ui: "./components/ui",
},
}
)
expect(result?.files).toHaveLength(2)
expect(result?.files?.[0].path).toBe("components/ui/base.tsx")
expect(result?.files?.[1].path).toBe("components/ui/extended.tsx")
})
})

View File

@@ -9,6 +9,9 @@ import {
npxShadcn,
} from "../utils/helpers"
// Note: The tests here intentionally do not use a mocked registry.
// We test this against the real registry.
describe("shadcn add", () => {
it("should add item to project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")

View File

@@ -2,7 +2,12 @@ import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
import {
createFixtureTestDirectory,
cssHasProperties,
npxShadcn,
} from "../utils/helpers"
import { createRegistryServer } from "../utils/registry"
describe("shadcn init - next-app", () => {
it("should init with default configuration", async () => {
@@ -92,7 +97,13 @@ describe("shadcn init - next-app", () => {
describe("shadcn init - vite-app", () => {
it("should init with custom alias and src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--base-color=gray", "alert-dialog"])
await npxShadcn(
fixturePath,
["init", "--base-color=gray", "alert-dialog"],
{
debug: true,
}
)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
@@ -131,3 +142,418 @@ describe("shadcn init - vite-app", () => {
)
})
})
describe("shadcn init - custom style", async () => {
const customRegistry = await createRegistryServer(
[
{
name: "style",
type: "registry:style",
files: [
{
path: "path/to/foo.ts",
content: "const foo = 'bar'",
type: "registry:lib",
},
],
cssVars: {
theme: {
"font-sans": "DM Sans, sans-serif",
},
light: {
primary: "#dc2626",
"foo-var": "3rem",
},
dark: {
"custom-brand": "#fef3c7",
"foo-var": "1rem",
},
},
},
{
name: "style-extended",
type: "registry:style",
registryDependencies: ["http://localhost:4445/r/style.json"],
files: [
{
path: "path/to/foo.ts",
content: "const foo = 'baz-qux'",
type: "registry:lib",
},
],
cssVars: {
theme: {
"font-sans": "Geist Sans, sans-serif",
"font-mono": "Geist Mono, monospace",
},
light: {
primary: "#059669",
secondary: "#06b6d4",
},
dark: {
"foo-var": "2rem",
},
},
},
{
name: "style-extend-none",
type: "registry:style",
extends: "none",
registryDependencies: ["http://localhost:4445/r/style.json"],
files: [
{
path: "path/to/foo.ts",
content: "const foo = 'baz-qux'",
type: "registry:lib",
},
],
cssVars: {
theme: {
"font-sans": "Geist Sans, sans-serif",
"font-mono": "Geist Mono, monospace",
},
light: {
primary: "#059669",
secondary: "#06b6d4",
},
dark: {
"foo-var": "2rem",
},
},
},
],
{
port: 4445,
}
)
beforeAll(async () => {
await customRegistry.start()
})
afterAll(async () => {
await customRegistry.stop()
})
it("should init with style that extends shadcn", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "http://localhost:4445/r/style.json"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
// Install utils from shadcn.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
true
)
// Then add foo.ts from the custom registry.
expect(
await fs.readFile(path.join(fixturePath, "lib/foo.ts"), "utf-8")
).toBe("const foo = 'bar'")
const globalCssContent = await fs.readFile(
path.join(fixturePath, "app/globals.css"),
"utf-8"
)
expect(globalCssContent).toContain("@layer base")
expect(globalCssContent).toContain(":root")
expect(globalCssContent).toContain(".dark")
expect(globalCssContent).toContain("tw-animate-css")
expect(
cssHasProperties(globalCssContent, [
{
selector: "@theme inline",
properties: {
"--font-sans": "DM Sans, sans-serif",
"--color-custom-brand": "var(--custom-brand)",
"--foo-var": "var(--foo-var)",
},
},
{
selector: ":root",
properties: {
"--background": "oklch(1 0 0)",
"--foreground": "oklch(0.145 0 0)",
"--primary": "#dc2626",
"--foo-var": "3rem",
},
},
{
selector: ".dark",
properties: {
"--background": "oklch(0.145 0 0)",
"--foreground": "oklch(0.985 0 0)",
"--primary": "oklch(0.922 0 0)",
"--custom-brand": "#fef3c7",
"--foo-var": "1rem",
},
},
])
).toBe(true)
})
it("should init with style that extends another style", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, [
"init",
"http://localhost:4445/r/style-extended.json",
])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
// Install utils from shadcn.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
true
)
// Then add foo.ts from the custom registry with overriden payload.
expect(
await fs.readFile(path.join(fixturePath, "lib/foo.ts"), "utf-8")
).toBe("const foo = 'baz-qux'")
const globalCssContent = await fs.readFile(
path.join(fixturePath, "app/globals.css"),
"utf-8"
)
expect(globalCssContent).toContain("@layer base")
expect(globalCssContent).toContain(":root")
expect(globalCssContent).toContain(".dark")
expect(globalCssContent).toContain("tw-animate-css")
expect(
cssHasProperties(globalCssContent, [
{
selector: "@theme inline",
properties: {
"--font-sans": "Geist Sans, sans-serif",
"--font-mono": "Geist Mono, monospace",
"--color-custom-brand": "var(--custom-brand)",
"--foo-var": "var(--foo-var)",
},
},
{
selector: ":root",
properties: {
"--background": "oklch(1 0 0)",
"--foreground": "oklch(0.145 0 0)",
"--primary": "#059669",
"--secondary": "#06b6d4",
"--foo-var": "3rem",
},
},
{
selector: ".dark",
properties: {
"--background": "oklch(0.145 0 0)",
"--foreground": "oklch(0.985 0 0)",
"--primary": "oklch(0.922 0 0)",
"--custom-brand": "#fef3c7",
"--foo-var": "2rem",
},
},
])
).toBe(true)
})
it("should init with --no-base-style", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--no-base-style"])
// We still expect components.json to be created.
// With some defaults.
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
// No utils should be installed.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
false
)
// The css file should only have tailwind imports.
expect(
await fs.readFile(path.join(fixturePath, "app/globals.css"), "utf-8")
).toMatchInlineSnapshot(`
"@import "tailwindcss";
"
`)
})
it("should init with custom style and --no-base-style", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, [
"init",
"http://localhost:4445/r/style-extended.json",
"--no-base-style",
])
// We still expect components.json to be created.
// With some defaults.
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
// No utils should be installed.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
false
)
// But we should have the foo.ts from the custom style.
expect(
await fs.readFile(path.join(fixturePath, "lib/foo.ts"), "utf-8")
).toBe("const foo = 'baz-qux'")
expect(
await fs.readFile(path.join(fixturePath, "app/globals.css"), "utf-8")
).toMatchInlineSnapshot(`
"@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: Geist Sans, sans-serif;
--font-mono: Geist Mono, monospace;
--color-custom-brand: var(--custom-brand);
--color-secondary: var(--secondary);
--foo-var: var(--foo-var);
--color-primary: var(--primary);
}
:root {
--primary: #059669;
--foo-var: 3rem;
--secondary: #06b6d4;
}
.dark {
--custom-brand: #fef3c7;
--foo-var: 2rem;
}
"
`)
})
it("should init with custom style extended none", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, [
"init",
"http://localhost:4445/r/style-extend-none.json",
])
// We still expect components.json to be created.
// With some defaults.
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
// No utils should be installed.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
false
)
// But we should have the foo.ts from the custom style.
expect(
await fs.readFile(path.join(fixturePath, "lib/foo.ts"), "utf-8")
).toBe("const foo = 'baz-qux'")
expect(
await fs.readFile(path.join(fixturePath, "app/globals.css"), "utf-8")
).toMatchInlineSnapshot(`
"@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: Geist Sans, sans-serif;
--font-mono: Geist Mono, monospace;
--color-custom-brand: var(--custom-brand);
--color-secondary: var(--secondary);
--foo-var: var(--foo-var);
--color-primary: var(--primary);
}
:root {
--primary: #059669;
--foo-var: 3rem;
--secondary: #06b6d4;
}
.dark {
--custom-brand: #fef3c7;
--foo-var: 2rem;
}
"
`)
})
})
describe("shadcn init - existing components.json", () => {
it("should override existing components.json when using --force", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
// Run init with default configuration.
await npxShadcn(fixturePath, ["init", "--base-color=neutral"])
// Override style in components.json.
const componentsJsonPath = path.join(fixturePath, "components.json")
const config = await fs.readJson(componentsJsonPath)
config.style = "custom-style"
await fs.writeJson(componentsJsonPath, config)
// Reinit with --force and different base color.
await npxShadcn(fixturePath, ["init", "--force", "--base-color=zinc"])
const newConfig = await fs.readJson(componentsJsonPath)
expect(newConfig.style).toBe("new-york")
expect(newConfig.tailwind.baseColor).toBe("zinc")
expect(await fs.pathExists(componentsJsonPath + ".bak")).toBe(false)
})
it("should restore backup components.json on error", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
const existingConfig = {
$schema: "https://ui.shadcn.com/schema.json",
style: "default",
tailwind: {
css: "app/globals.css",
baseColor: "zinc",
cssVariables: false,
},
rsc: true,
tsx: true,
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
}
const componentsJsonPath = path.join(fixturePath, "components.json")
await fs.writeJson(componentsJsonPath, existingConfig)
// Run init with an invalid component - this should fail and restore
await npxShadcn(fixturePath, [
"init",
"invalid-component-that-does-not-exist",
])
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
const newConfig = await fs.readJson(componentsJsonPath)
expect(newConfig).toMatchObject(existingConfig)
expect(await fs.pathExists(componentsJsonPath + ".bak")).toBe(false)
})
})

View File

@@ -88,6 +88,27 @@ const registryShadcn = await createRegistryServer(
const registryOne = await createRegistryServer(
[
{
name: "style",
type: "registry:style",
cssVars: {
theme: {
"font-sans": "Inter, sans-serif",
},
light: {
brand: "oklch(20 14.3% 4.1%)",
"brand-foreground": "oklch(24 1.3% 10%)",
},
dark: {
brand: "oklch(24 1.3% 10%)",
},
},
css: {
button: {
cursor: "pointer",
},
},
},
{
name: "foo",
type: "registry:component",
@@ -175,6 +196,27 @@ const registryOne = await createRegistryServer(
const registryTwo = await createRegistryServer(
[
{
name: "style",
type: "registry:style",
cssVars: {
theme: {
"font-serif": "Garamond, serif",
},
light: {
neutral: "oklch(10 14.3% 4.1%)",
"neutral-foreground": "oklch(24 3% 10%)",
},
dark: {
neutral: "oklch(24 1.3% 10%)",
},
},
css: {
button: {
cursor: "pointer",
},
},
},
{
name: "one",
type: "registry:file",
@@ -682,12 +724,7 @@ describe("registries", () => {
process.env.NEXT_PUBLIC_REGISTRY_URL = "http://localhost:4444/r"
process.env.REGISTRY_TOKEN = "EXAMPLE_REGISTRY_TOKEN"
process.env.REGISTRY_API_KEY = "EXAMPLE_API_KEY"
const output = await npxShadcn(fixturePath, [
"add",
"@one/foo",
"@three/baz",
"@two/two",
])
await npxShadcn(fixturePath, ["add", "@one/foo", "@three/baz", "@two/two"])
expect(
await fs.pathExists(path.join(fixturePath, "components/foo.tsx"))
@@ -1146,3 +1183,148 @@ describe("registries", () => {
).toBe("Foo Bar")
})
})
describe("registries:init", () => {
beforeEach(async () => {
process.env.REGISTRY_URL = "http://localhost:4000/r"
})
it("should error when init with unconfigured registries", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
const output = await npxShadcn(fixturePath, ["init", "@two/style"])
expect(output.stdout).toContain('Unknown registry "@two"')
})
it("should init from registry:style", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await configureRegistries(fixturePath, {
"@one": "http://localhost:4444/r/{name}",
})
await npxShadcn(fixturePath, ["init", "@one/style"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
expect(componentsJson.registries).toMatchInlineSnapshot(`
{
"@one": "http://localhost:4444/r/{name}",
}
`)
// Install utils from shadcn.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
true
)
const globalCssContent = await fs.readFile(
path.join(fixturePath, "app/globals.css"),
"utf-8"
)
expect(
cssHasProperties(globalCssContent, [
{
selector: "@theme inline",
properties: {
"--font-sans": "Inter, sans-serif",
"--color-brand": "var(--brand)",
"--color-brand-foreground": "var(--brand-foreground)",
},
},
{
selector: ":root",
properties: {
"--background": "oklch(1 0 0)",
"--foreground": "oklch(0.145 0 0)",
"--brand": "oklch(20 14.3% 4.1%)",
"--brand-foreground": "oklch(24 1.3% 10%)",
},
},
{
selector: ".dark",
properties: {
"--background": "oklch(0.145 0 0)",
"--foreground": "oklch(0.985 0 0)",
"--brand": "oklch(24 1.3% 10%)",
},
},
])
).toBe(true)
})
it("should init from registry:style with auth", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await configureRegistries(fixturePath, {
"@two": {
url: "http://localhost:5555/registry/bearer/{name}",
headers: {
Authorization: "Bearer ${BEARER_TOKEN}",
},
},
})
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN"
await npxShadcn(fixturePath, ["init", "@two/style"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("new-york")
expect(componentsJson.tailwind.baseColor).toBe("neutral")
expect(componentsJson.registries).toMatchInlineSnapshot(`
{
"@two": {
"headers": {
"Authorization": "Bearer \${BEARER_TOKEN}",
},
"url": "http://localhost:5555/registry/bearer/{name}",
},
}
`)
// Install utils from shadcn.
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
true
)
const globalCssContent = await fs.readFile(
path.join(fixturePath, "app/globals.css"),
"utf-8"
)
expect(
cssHasProperties(globalCssContent, [
{
selector: "@theme inline",
properties: {
"--font-serif": "Garamond, serif",
"--color-neutral": "var(--neutral)",
"--color-neutral-foreground": "var(--neutral-foreground)",
},
},
{
selector: ":root",
properties: {
"--background": "oklch(1 0 0)",
"--foreground": "oklch(0.145 0 0)",
"--neutral": "oklch(10 14.3% 4.1%)",
"--neutral-foreground": "oklch(24 3% 10%)",
},
},
{
selector: ".dark",
properties: {
"--background": "oklch(0.145 0 0)",
"--foreground": "oklch(0.985 0 0)",
"--neutral": "oklch(24 1.3% 10%)",
},
},
])
).toBe(true)
})
})

View File

@@ -64,12 +64,26 @@ export async function runCommand(
}
}
export async function npxShadcn(cwd: string, args: string[]) {
return runCommand(cwd, args, {
export async function npxShadcn(
cwd: string,
args: string[],
{
debug = false,
}: {
debug?: boolean
} = {}
) {
const result = await runCommand(cwd, args, {
env: {
REGISTRY_URL: getRegistryUrl(),
},
})
if (debug) {
console.log(result)
}
return result
}
export function cssHasProperties(

View File

@@ -75,6 +75,23 @@ export async function createRegistryServer(
return
}
if (urlWithoutQuery?.includes("styles/index")) {
response.writeHead(200, { "Content-Type": "application/json" })
response.end(
JSON.stringify([
{
name: "new-york",
label: "New York",
},
{
name: "default",
label: "Default",
},
])
)
return
}
if (urlWithoutQuery?.includes("index")) {
response.writeHead(200, { "Content-Type": "application/json" })
response.end(
@@ -193,7 +210,7 @@ export async function configureRegistries(
) {
if (!fs.pathExistsSync(path.join(fixturePath, "components.json"))) {
await fs.writeJSON(path.join(fixturePath, "components.json"), {
payload,
registries: payload,
})
}