From 2c164b0f221fac0367a0eda3ce8502b38b25ce3e Mon Sep 17 00:00:00 2001 From: shadcn Date: Wed, 6 Aug 2025 13:38:08 +0400 Subject: [PATCH] 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 --- .changeset/rare-sloths-run.md | 5 + packages/shadcn/src/commands/add.ts | 4 +- packages/shadcn/src/commands/init.ts | 109 ++++- packages/shadcn/src/registry/api.ts | 271 ++++++++++- packages/shadcn/src/registry/builder.ts | 4 +- packages/shadcn/src/registry/resolver.ts | 2 +- packages/shadcn/src/utils/add-components.ts | 18 +- packages/shadcn/src/utils/file-helper.ts | 52 +++ packages/shadcn/src/utils/get-project-info.ts | 6 +- .../registry-resolve-items-tree.test.ts.snap | 264 +++++------ .../registry-resolve-items-tree.test.ts | 316 ++++++++++++- packages/tests/src/tests/add.test.ts | 3 + packages/tests/src/tests/init.test.ts | 430 +++++++++++++++++- packages/tests/src/tests/registries.test.ts | 194 +++++++- packages/tests/src/utils/helpers.ts | 18 +- packages/tests/src/utils/registry.ts | 19 +- 16 files changed, 1514 insertions(+), 201 deletions(-) create mode 100644 .changeset/rare-sloths-run.md create mode 100644 packages/shadcn/src/utils/file-helper.ts diff --git a/.changeset/rare-sloths-run.md b/.changeset/rare-sloths-run.md new file mode 100644 index 0000000000..c4d674b667 --- /dev/null +++ b/.changeset/rare-sloths-run.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +update registry dependencies resolution algorithm diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts index 811e942000..205b042f0d 100644 --- a/packages/shadcn/src/commands/add.ts +++ b/packages/shadcn/src/commands/add.ts @@ -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 = diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 81a4f52ee4..fb0a8cf345 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -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[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", }) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index eca5c19ce7..394b08b1ff 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -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 { + config?: Parameters[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[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, "type">, @@ -356,7 +367,11 @@ export function clearRegistryCache() { registryCache.clear() } -async function getResolvedStyle(config?: Config) { +async function getResolvedStyle( + config?: Pick & { + resolvedPaths: Pick + } +) { if (!config) { return undefined } @@ -369,19 +384,25 @@ async function getResolvedStyle(config?: Config) { export async function fetchFromRegistry( items: `${string}/registry`[], - config?: Config, + config?: Pick & { + resolvedPaths: Pick + }, options?: { useCache?: boolean } ): Promise[]> export async function fetchFromRegistry( items: string[], - config?: Config, + config?: Pick & { + resolvedPaths: Pick + }, options?: { useCache?: boolean } ): Promise[]> export async function fetchFromRegistry( items: string[], - config?: Config, + config?: Pick & { + resolvedPaths: Pick + }, options: { useCache?: boolean } = {} ): Promise< (z.infer | z.infer)[] @@ -414,7 +435,7 @@ export async function fetchFromRegistry( async function resolveDependenciesRecursively( dependencies: string[], - config?: Config, + config?: Pick, visited: Set = new Set() ) { const items: z.infer[] = [] @@ -520,20 +541,29 @@ export async function registryResolveItemsTree( (name) => !isLocalFile(name) && !isUrl(name) ) - const payload: z.infer[] = [] + let payload: z.infer[] = [] // Handle local files and URLs directly, resolving their dependencies individually. - let allDependencyItems: z.infer[] = [] + let allDependencyItems: z.infer[] = [] 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 = { + ...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 = { + ...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[] = + 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, 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, "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[], + sourceMap: Map, string> +) { + const itemMap = new Map>() + const hashToItem = new Map>() + const inDegree = new Map() + const adjacencyList = new Map() + + 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() + 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[] = [] + + 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 +} diff --git a/packages/shadcn/src/registry/builder.ts b/packages/shadcn/src/registry/builder.ts index 089da051f5..a9d1e497b8 100644 --- a/packages/shadcn/src/registry/builder.ts +++ b/packages/shadcn/src/registry/builder.ts @@ -13,7 +13,7 @@ const QUERY_PARAM_DELIMITER = "&" export function buildUrlAndHeadersForRegistryItem( name: string, - config?: z.infer + config?: Pick, "registries" | "style"> ) { const { registry, item } = parseRegistryAndItemFromString(name) @@ -43,7 +43,7 @@ export function buildUrlAndHeadersForRegistryItem( export function buildUrlFromRegistryConfig( item: string, registryConfig: z.infer, - config?: z.infer + config?: Pick, "style"> ) { if (typeof registryConfig === "string") { let url = registryConfig.replace(NAME_PLACEHOLDER, item) diff --git a/packages/shadcn/src/registry/resolver.ts b/packages/shadcn/src/registry/resolver.ts index 33d4183141..41564b14f5 100644 --- a/packages/shadcn/src/registry/resolver.ts +++ b/packages/shadcn/src/registry/resolver.ts @@ -6,7 +6,7 @@ import { setRegistryHeaders } from "./context" export function resolveRegistryItemsFromRegistries( items: string[], - config?: z.infer + config?: Pick, "registries" | "style"> ) { const registryHeaders: Record> = {} const resolvedItems = [...items] diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index d18914e6bb..ca00845303 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -39,7 +39,7 @@ export async function addComponents( overwrite?: boolean silent?: boolean isNewProject?: boolean - style?: string + baseStyle?: boolean registryHeaders?: Record> } ) { @@ -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() diff --git a/packages/shadcn/src/utils/file-helper.ts b/packages/shadcn/src/utils/file-helper.ts new file mode 100644 index 0000000000..cebd807f7d --- /dev/null +++ b/packages/shadcn/src/utils/file-helper.ts @@ -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 + } +} diff --git a/packages/shadcn/src/utils/get-project-info.ts b/packages/shadcn/src/utils/get-project-info.ts index 2c15fb43c8..0157c316c7 100644 --- a/packages/shadcn/src/utils/get-project-info.ts +++ b/packages/shadcn/src/utils/get-project-info.ts @@ -353,9 +353,9 @@ export async function getProjectConfig( return await resolveConfigPaths(cwd, config) } -export async function getProjectTailwindVersionFromConfig( - config: Config -): Promise { +export async function getProjectTailwindVersionFromConfig(config: { + resolvedPaths: Pick +}): Promise { if (!config.resolvedPaths?.cwd) { return "v3" } diff --git a/packages/shadcn/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap b/packages/shadcn/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap index 91e0fe90d7..0766298c49 100644 --- a/packages/shadcn/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap +++ b/packages/shadcn/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -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": {}, } diff --git a/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts b/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts index 8a35289a4f..c2e26b8ad0 100644 --- a/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts +++ b/packages/shadcn/test/utils/schema/registry-resolve-items-tree.test.ts @@ -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> + + 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 = () =>
Base Component
", + 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 = () =>
Extended Component
", + 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 = () =>
Deep Component
", + 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 = () =>
A
", + 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 = () =>
B
", + 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 = () =>
Alternative Base Component
", + 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") + }) +}) diff --git a/packages/tests/src/tests/add.test.ts b/packages/tests/src/tests/add.test.ts index 7c835eb57b..40a99e6885 100644 --- a/packages/tests/src/tests/add.test.ts +++ b/packages/tests/src/tests/add.test.ts @@ -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") diff --git a/packages/tests/src/tests/init.test.ts b/packages/tests/src/tests/init.test.ts index ea45d10e9c..feb69916a0 100644 --- a/packages/tests/src/tests/init.test.ts +++ b/packages/tests/src/tests/init.test.ts @@ -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) + }) +}) diff --git a/packages/tests/src/tests/registries.test.ts b/packages/tests/src/tests/registries.test.ts index 126ad82224..26af3e5e2e 100644 --- a/packages/tests/src/tests/registries.test.ts +++ b/packages/tests/src/tests/registries.test.ts @@ -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) + }) +}) diff --git a/packages/tests/src/utils/helpers.ts b/packages/tests/src/utils/helpers.ts index a031cc94e9..28ba9891f9 100644 --- a/packages/tests/src/utils/helpers.ts +++ b/packages/tests/src/utils/helpers.ts @@ -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( diff --git a/packages/tests/src/utils/registry.ts b/packages/tests/src/utils/registry.ts index 7c9bffd514..df47fd2e11 100644 --- a/packages/tests/src/utils/registry.ts +++ b/packages/tests/src/utils/registry.ts @@ -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, }) }