diff --git a/.changeset/two-jobs-swim.md b/.changeset/two-jobs-swim.md new file mode 100644 index 000000000..f3ddfdec2 --- /dev/null +++ b/.changeset/two-jobs-swim.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add universal registry items support diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts index dadbbdcc8..c2a795da7 100644 --- a/packages/shadcn/src/commands/add.ts +++ b/packages/shadcn/src/commands/add.ts @@ -1,13 +1,18 @@ +import fs from "fs" import path from "path" import { runInit } from "@/src/commands/init" import { preFlightAdd } from "@/src/preflights/preflight-add" import { getRegistryIndex, getRegistryItem } from "@/src/registry/api" import { registryItemTypeSchema } from "@/src/registry/schema" -import { isLocalFile, isUrl } from "@/src/registry/utils" +import { + isLocalFile, + isUniversalRegistryItem, + isUrl, +} from "@/src/registry/utils" import { addComponents } from "@/src/utils/add-components" import { createProject } from "@/src/utils/create-project" import * as ERRORS from "@/src/utils/errors" -import { getConfig } from "@/src/utils/get-config" +import { createConfig, getConfig } from "@/src/utils/get-config" import { getProjectInfo } from "@/src/utils/get-project-info" import { handleError } from "@/src/utils/handle-error" import { highlighter } from "@/src/utils/highlighter" @@ -78,13 +83,14 @@ export const add = new Command() }) let itemType: z.infer | undefined + let registryItem: any = null if ( components.length > 0 && (isUrl(components[0]) || isLocalFile(components[0])) ) { - const item = await getRegistryItem(components[0], "") - itemType = item?.type + registryItem = await getRegistryItem(components[0], "") + itemType = registryItem?.type } if ( @@ -130,6 +136,22 @@ export const add = new Command() } } + if (isUniversalRegistryItem(registryItem)) { + // Universal items only cares about the cwd. + if (!fs.existsSync(options.cwd)) { + throw new Error(`Directory ${options.cwd} does not exist`) + } + + const minimalConfig = createConfig({ + resolvedPaths: { + cwd: options.cwd, + }, + }) + + await addComponents(options.components, minimalConfig, options) + return + } + let { errors, config } = await preFlightAdd(options) // No components.json file. Prompt the user to run init. diff --git a/packages/shadcn/src/registry/utils.test.ts b/packages/shadcn/src/registry/utils.test.ts index 42fe499aa..86fbf4cab 100644 --- a/packages/shadcn/src/registry/utils.test.ts +++ b/packages/shadcn/src/registry/utils.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest" -import { getDependencyFromModuleSpecifier, isLocalFile, isUrl } from "./utils" +import { + getDependencyFromModuleSpecifier, + isLocalFile, + isUniversalRegistryItem, + isUrl, +} from "./utils" describe("getDependencyFromModuleSpecifier", () => { it("should return the first part of a non-scoped package with path", () => { @@ -130,3 +135,139 @@ describe("isLocalFile", () => { expect(isLocalFile("/absolute/path")).toBe(false) }) }) + +describe("isUniversalRegistryItem", () => { + it("should return true when all files have targets", () => { + const registryItem = { + files: [ + { + path: "file1.ts", + target: "src/file1.ts", + type: "registry:lib" as const, + }, + { + path: "file2.ts", + target: "src/utils/file2.ts", + type: "registry:lib" as const, + }, + ], + } + expect(isUniversalRegistryItem(registryItem)).toBe(true) + }) + + it("should return false when some files lack targets", () => { + const registryItem = { + files: [ + { + path: "file1.ts", + target: "src/file1.ts", + type: "registry:lib" as const, + }, + { path: "file2.ts", target: "", type: "registry:lib" as const }, + ], + } + expect(isUniversalRegistryItem(registryItem)).toBe(false) + }) + + it("should return false when no files have targets", () => { + const registryItem = { + files: [ + { path: "file1.ts", target: "", type: "registry:lib" as const }, + { path: "file2.ts", target: "", type: "registry:lib" as const }, + ], + } + expect(isUniversalRegistryItem(registryItem)).toBe(false) + }) + + it("should return false when files array is empty", () => { + const registryItem = { + files: [], + } + expect(isUniversalRegistryItem(registryItem)).toBe(false) + }) + + it("should return false when files is undefined", () => { + const registryItem = {} + expect(isUniversalRegistryItem(registryItem)).toBe(false) + }) + + it("should return false when registryItem is null", () => { + expect(isUniversalRegistryItem(null)).toBe(false) + }) + + it("should return false when registryItem is undefined", () => { + expect(isUniversalRegistryItem(undefined)).toBe(false) + }) + + it("should return false when target is null", () => { + const registryItem = { + files: [ + { + path: "file1.ts", + target: null as any, + type: "registry:lib" as const, + }, + ], + } + expect(isUniversalRegistryItem(registryItem)).toBe(false) + }) + + it("should return false when target is undefined", () => { + const registryItem = { + files: [{ path: "file1.ts", type: "registry:lib" as const }], + } + expect(isUniversalRegistryItem(registryItem)).toBe(false) + }) + + it("should handle mixed file types correctly", () => { + const registryItem = { + files: [ + { + path: "component.tsx", + target: "components/ui/component.tsx", + type: "registry:ui" as const, + }, + { + path: "utils.ts", + target: "lib/utils.ts", + type: "registry:lib" as const, + }, + { + path: "hook.ts", + target: "hooks/use-something.ts", + type: "registry:hook" as const, + }, + ], + } + expect(isUniversalRegistryItem(registryItem)).toBe(true) + }) + + it("should return true when all targets are non-empty strings", () => { + const registryItem = { + files: [ + { path: "file1.ts", target: " ", type: "registry:lib" as const }, // whitespace is truthy + { path: "file2.ts", target: "0", type: "registry:lib" as const }, // "0" is truthy + ], + } + expect(isUniversalRegistryItem(registryItem)).toBe(true) + }) + + it("should handle real-world example with path traversal attempts", () => { + const registryItem = { + files: [ + { + path: "malicious.ts", + target: "../../../etc/passwd", + type: "registry:lib" as const, + }, + { + path: "normal.ts", + target: "src/normal.ts", + type: "registry:lib" as const, + }, + ], + } + // The function should still return true - path validation is handled elsewhere + expect(isUniversalRegistryItem(registryItem)).toBe(true) + }) +}) diff --git a/packages/shadcn/src/registry/utils.ts b/packages/shadcn/src/registry/utils.ts index 9879bfb44..3bb40e483 100644 --- a/packages/shadcn/src/registry/utils.ts +++ b/packages/shadcn/src/registry/utils.ts @@ -256,3 +256,20 @@ export function isUrl(path: string) { export function isLocalFile(path: string) { return path.endsWith(".json") && !isUrl(path) } + +/** + * Check if a registry item is universal (framework-agnostic). + * A universal registry item has all files with explicit targets. + * It can be installed without framework detection or components.json. + */ +export function isUniversalRegistryItem( + registryItem: + | Pick, "files"> + | null + | undefined +): boolean { + return ( + !!registryItem?.files?.length && + registryItem.files.every((file) => !!file.target) + ) +} diff --git a/packages/shadcn/src/utils/get-config.ts b/packages/shadcn/src/utils/get-config.ts index 145d684b6..4e368bcc7 100644 --- a/packages/shadcn/src/utils/get-config.ts +++ b/packages/shadcn/src/utils/get-config.ts @@ -225,3 +225,64 @@ export async function getTargetStyleFromConfig(cwd: string, fallback: string) { const projectInfo = await getProjectInfo(cwd) return projectInfo?.tailwindVersion === "v4" ? "new-york-v4" : fallback } + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] +} + +/** + * Creates a config object with sensible defaults. + * Useful for universal registry items that bypass framework detection. + * + * @param partial - Partial config values to override defaults + * @returns A complete Config object + */ +export function createConfig(partial?: DeepPartial): Config { + const defaultConfig: Config = { + resolvedPaths: { + cwd: process.cwd(), + tailwindConfig: "", + tailwindCss: "", + utils: "", + components: "", + ui: "", + lib: "", + hooks: "", + }, + style: "", + tailwind: { + config: "", + css: "", + baseColor: "", + cssVariables: false, + }, + rsc: false, + tsx: true, + aliases: { + components: "", + utils: "", + }, + } + + // Deep merge the partial config with defaults + if (partial) { + return { + ...defaultConfig, + ...partial, + resolvedPaths: { + ...defaultConfig.resolvedPaths, + ...(partial.resolvedPaths || {}), + }, + tailwind: { + ...defaultConfig.tailwind, + ...(partial.tailwind || {}), + }, + aliases: { + ...defaultConfig.aliases, + ...(partial.aliases || {}), + }, + } + } + + return defaultConfig +} diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index 0b87d5283..f81746c12 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -51,7 +51,9 @@ export async function updateFiles( const [projectInfo, baseColor] = await Promise.all([ getProjectInfo(config.resolvedPaths.cwd), - getRegistryBaseColor(config.tailwind.baseColor), + config.tailwind.baseColor + ? getRegistryBaseColor(config.tailwind.baseColor) + : Promise.resolve(undefined), ]) let filesCreated: string[] = [] diff --git a/packages/shadcn/test/utils/get-config.test.ts b/packages/shadcn/test/utils/get-config.test.ts index 908b0b356..f8f6affba 100644 --- a/packages/shadcn/test/utils/get-config.test.ts +++ b/packages/shadcn/test/utils/get-config.test.ts @@ -1,7 +1,11 @@ import path from "path" -import { expect, test } from "vitest" +import { describe, expect, test } from "vitest" -import { getConfig, getRawConfig } from "../../src/utils/get-config" +import { + createConfig, + getConfig, + getRawConfig, +} from "../../src/utils/get-config" test("get raw config", async () => { expect( @@ -183,3 +187,129 @@ test("get config", async () => { }, }) }) + +describe("createConfig", () => { + test("creates default config when called without arguments", () => { + const config = createConfig() + + expect(config).toMatchObject({ + resolvedPaths: { + cwd: expect.any(String), + tailwindConfig: "", + tailwindCss: "", + utils: "", + components: "", + ui: "", + lib: "", + hooks: "", + }, + style: "", + tailwind: { + config: "", + css: "", + baseColor: "", + cssVariables: false, + }, + rsc: false, + tsx: true, + aliases: { + components: "", + utils: "", + }, + }) + }) + + test("overrides cwd in resolvedPaths", () => { + const customCwd = "/custom/path" + const config = createConfig({ + resolvedPaths: { + cwd: customCwd, + }, + }) + + expect(config.resolvedPaths.cwd).toBe(customCwd) + expect(config.resolvedPaths.components).toBe("") + expect(config.resolvedPaths.utils).toBe("") + }) + + test("overrides style", () => { + const config = createConfig({ + style: "new-york", + }) + + expect(config.style).toBe("new-york") + }) + + test("overrides tailwind settings", () => { + const config = createConfig({ + tailwind: { + baseColor: "slate", + cssVariables: true, + }, + }) + + expect(config.tailwind.baseColor).toBe("slate") + expect(config.tailwind.cssVariables).toBe(true) + expect(config.tailwind.config).toBe("") + expect(config.tailwind.css).toBe("") + }) + + test("overrides boolean flags", () => { + const config = createConfig({ + rsc: true, + tsx: false, + }) + + expect(config.rsc).toBe(true) + expect(config.tsx).toBe(false) + }) + + test("overrides aliases", () => { + const config = createConfig({ + aliases: { + components: "@/components", + utils: "@/lib/utils", + }, + }) + + expect(config.aliases.components).toBe("@/components") + expect(config.aliases.utils).toBe("@/lib/utils") + }) + + test("handles complex partial overrides", () => { + const config = createConfig({ + style: "default", + resolvedPaths: { + cwd: "/my/project", + components: "/my/project/src/components", + }, + tailwind: { + baseColor: "zinc", + prefix: "tw-", + }, + aliases: { + ui: "@/components/ui", + }, + }) + + expect(config.style).toBe("default") + expect(config.resolvedPaths.cwd).toBe("/my/project") + expect(config.resolvedPaths.components).toBe("/my/project/src/components") + expect(config.resolvedPaths.utils).toBe("") + expect(config.tailwind.baseColor).toBe("zinc") + expect(config.tailwind.prefix).toBe("tw-") + expect(config.tailwind.css).toBe("") + expect(config.aliases.ui).toBe("@/components/ui") + expect(config.aliases.components).toBe("") + }) + + test("returns new object instances", () => { + const config1 = createConfig() + const config2 = createConfig() + + expect(config1).not.toBe(config2) + expect(config1.resolvedPaths).not.toBe(config2.resolvedPaths) + expect(config1.tailwind).not.toBe(config2.tailwind) + expect(config1.aliases).not.toBe(config2.aliases) + }) +})