mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-29 15:44:22 +00:00
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:
5
.changeset/rare-sloths-run.md
Normal file
5
.changeset/rare-sloths-run.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
update registry dependencies resolution algorithm
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
52
packages/shadcn/src/utils/file-helper.ts
Normal file
52
packages/shadcn/src/utils/file-helper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user