diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index 4378b5895c..795b939280 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -36,6 +36,7 @@ export async function addComponents( isNewProject?: boolean baseStyle?: boolean registryHeaders?: Record> + path?: string } ) { options = { @@ -70,6 +71,7 @@ async function addProjectComponents( silent?: boolean isNewProject?: boolean baseStyle?: boolean + path?: string } ) { if (!options.baseStyle && !components.length) { @@ -127,6 +129,7 @@ async function addProjectComponents( await updateFiles(tree.files, config, { overwrite: options.overwrite, silent: options.silent, + path: options.path, }) if (tree.docs) { @@ -144,6 +147,7 @@ async function addWorkspaceComponents( isNewProject?: boolean isRemote?: boolean baseStyle?: boolean + path?: string } ) { if (!options.baseStyle && !components.length) { @@ -275,6 +279,7 @@ async function addWorkspaceComponents( rootSpinner, isRemote: options.isRemote, isWorkspace: true, + path: options.path, }) filesCreated.push( diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index 18cf05d500..3225fbdf3a 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -1,4 +1,4 @@ -import { existsSync, promises as fs } from "fs" +import { existsSync, promises as fs, statSync } from "fs" import { tmpdir } from "os" import path, { basename } from "path" import { getRegistryBaseColor } from "@/src/registry/api" @@ -38,6 +38,7 @@ export async function updateFiles( rootSpinner?: ReturnType isRemote?: boolean isWorkspace?: boolean + path?: string } ) { if (!files?.length) { @@ -72,7 +73,8 @@ export async function updateFiles( let envVarsAdded: string[] = [] let envFile: string | null = null - for (const file of files) { + for (let index = 0; index < files.length; index++) { + const file = files[index] if (!file.content) { continue } @@ -84,6 +86,8 @@ export async function updateFiles( files.map((f) => f.path), file.path ), + path: options.path, + fileIndex: index, }) if (!filePath) { @@ -108,6 +112,13 @@ export async function updateFiles( const existingFile = existsSync(filePath) + // Check if the path exists and is a directory - we can't write to directories. + if (existingFile && statSync(filePath).isDirectory()) { + throw new Error( + `Cannot write to ${filePath}: path exists and is a directory. Please provide a file path instead.` + ) + } + // Run our transformers. // Skip transformers for .env files to preserve exact content const content = isEnvFile(filePath) @@ -307,8 +318,32 @@ export function resolveFilePath( isSrcDir?: boolean commonRoot?: string framework?: ProjectInfo["framework"]["name"] + path?: string + fileIndex?: number } ) { + // Handle custom path if provided. + if (options.path) { + const resolvedPath = path.isAbsolute(options.path) + ? options.path + : path.join(config.resolvedPaths.cwd, options.path) + + const isFilePath = /\.[^/\\]+$/.test(resolvedPath) + + if (isFilePath) { + // We'll only use the custom path for the first file. + // This is for registry items with multiple files. + if (options.fileIndex === 0) { + return resolvedPath + } + } else { + // If the custom path is a directory, + // We'll place all files in the directory. + const fileName = path.basename(file.path) + return path.join(resolvedPath, fileName) + } + } + if (file.target) { if (file.target.startsWith("~/")) { return path.join(config.resolvedPaths.cwd, file.target.replace("~/", "")) diff --git a/packages/shadcn/test/utils/updaters/update-files.test.ts b/packages/shadcn/test/utils/updaters/update-files.test.ts index 2975c68f42..25a93dd517 100644 --- a/packages/shadcn/test/utils/updaters/update-files.test.ts +++ b/packages/shadcn/test/utils/updaters/update-files.test.ts @@ -456,6 +456,258 @@ describe("resolveFilePath", () => { }) }) +describe("resolveFilePath with custom path", () => { + test("should use custom file path for exact file target", () => { + expect( + resolveFilePath( + { + path: "hello-world/ui/button.tsx", + type: "registry:ui", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom/my-button.tsx", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/my-button.tsx") + }) + + test("should use custom directory path and strip type prefix", () => { + expect( + resolveFilePath( + { + path: "hello-world/ui/button.tsx", + type: "registry:ui", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/button.tsx") + }) + + test("should strip nested paths when using custom directory", () => { + expect( + resolveFilePath( + { + path: "hello-world/components/nested/path/card.tsx", + type: "registry:component", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/card.tsx") + }) + + test("should handle lib files with custom directory", () => { + expect( + resolveFilePath( + { + path: "hello-world/lib/utils.ts", + type: "registry:lib", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/utils.ts") + }) + + test("should handle hooks with custom directory", () => { + expect( + resolveFilePath( + { + path: "hello-world/hooks/use-toast.ts", + type: "registry:hook", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/use-toast.ts") + }) + + test("should use custom file path with different extension", () => { + expect( + resolveFilePath( + { + path: "hello-world/ui/card.tsx", + type: "registry:ui", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/my-components/custom-card.jsx", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/my-components/custom-card.jsx") + }) + + test("should not use custom path when not provided", () => { + expect( + resolveFilePath( + { + path: "hello-world/ui/button.tsx", + type: "registry:ui", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + } + ) + ).toBe("/foo/bar/components/ui/button.tsx") + }) + + test("should support any file extension for file paths", () => { + // Test with .json + expect( + resolveFilePath( + { + path: "hello-world/config.json", + type: "registry:file", + target: "~/config.json", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom/my-config.json", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/my-config.json") + + // Test with .css + expect( + resolveFilePath( + { + path: "hello-world/styles.css", + type: "registry:file", + target: "~/styles.css", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/custom/theme.css", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/custom/theme.css") + + // Test with .md + expect( + resolveFilePath( + { + path: "hello-world/README.md", + type: "registry:file", + target: "~/README.md", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + { + isSrcDir: false, + path: "/foo/bar/docs/guide.md", + fileIndex: 0, + } + ) + ).toBe("/foo/bar/docs/guide.md") + }) +}) + describe("resolveFilePath with framework", () => { test("should not resolve for unknown or unsupported framework", () => { expect( @@ -1160,6 +1412,120 @@ DATABASE_URL=postgres://localhost:5432/mydb`, await fsActual.rm(tempDir, { recursive: true }).catch(() => {}) } }) + + test("should place first file at custom file path", async () => { + const config = await getConfig( + path.resolve(__dirname, "../../fixtures/vite-with-tailwind") + ) + expect( + await updateFiles( + [ + { + path: "registry/default/ui/button.tsx", + type: "registry:ui", + content: `export function Button() { + return +}`, + }, + ], + config, + { + overwrite: true, + silent: true, + path: "custom/my-button.tsx", + } + ) + ).toMatchInlineSnapshot(` + { + "filesCreated": [ + "custom/my-button.tsx", + ], + "filesSkipped": [], + "filesUpdated": [], + } + `) + }) + + test("should place all files in custom directory", async () => { + const config = await getConfig( + path.resolve(__dirname, "../../fixtures/vite-with-tailwind") + ) + expect( + await updateFiles( + [ + { + path: "registry/default/ui/button.tsx", + type: "registry:ui", + content: `export function Button() { + return +}`, + }, + { + path: "registry/default/ui/card.tsx", + type: "registry:ui", + content: `export function Card() { + return
Card
+}`, + }, + ], + config, + { + overwrite: true, + silent: true, + path: "custom/components", + } + ) + ).toMatchInlineSnapshot(` + { + "filesCreated": [ + "custom/components/button.tsx", + "custom/components/card.tsx", + ], + "filesSkipped": [], + "filesUpdated": [], + } + `) + }) + + test("should only apply file path to first file", async () => { + const config = await getConfig( + path.resolve(__dirname, "../../fixtures/vite-with-tailwind") + ) + expect( + await updateFiles( + [ + { + path: "registry/default/ui/button.tsx", + type: "registry:ui", + content: `export function Button() { + return +}`, + }, + { + path: "registry/default/lib/utils.ts", + type: "registry:lib", + content: `export function cn() {}`, + }, + ], + config, + { + overwrite: true, + silent: true, + path: "custom/my-button.tsx", + } + ) + ).toMatchInlineSnapshot(` + { + "filesCreated": [ + "custom/my-button.tsx", + ], + "filesSkipped": [], + "filesUpdated": [ + "src/lib/utils.ts", + ], + } + `) + }) }) describe("resolveModuleByProbablePath", () => { diff --git a/packages/tests/src/tests/add.test.ts b/packages/tests/src/tests/add.test.ts index 6819f4fe30..43ec6cbaa8 100644 --- a/packages/tests/src/tests/add.test.ts +++ b/packages/tests/src/tests/add.test.ts @@ -297,6 +297,57 @@ describe("shadcn add", () => { ).toBe("Foo Bar") }) + it("should add component to custom file path", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + await npxShadcn(fixturePath, ["init", "--base-color=neutral"]) + await npxShadcn(fixturePath, [ + "add", + "button", + "--path=custom/my-button.tsx", + ]) + + expect( + await fs.pathExists(path.join(fixturePath, "custom/my-button.tsx")) + ).toBe(true) + expect( + await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx")) + ).toBe(false) + }) + + it("should add component to custom directory", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + await npxShadcn(fixturePath, ["init", "--base-color=neutral"]) + await npxShadcn(fixturePath, ["add", "button", "--path=custom/components"]) + + expect( + await fs.pathExists( + path.join(fixturePath, "custom/components/button.tsx") + ) + ).toBe(true) + expect( + await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx")) + ).toBe(false) + }) + + it("should add multiple files to custom directory", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + await npxShadcn(fixturePath, ["init", "--base-color=neutral"]) + await npxShadcn(fixturePath, ["add", "button", "card", "--path=custom/ui"]) + + expect( + await fs.pathExists(path.join(fixturePath, "custom/ui/button.tsx")) + ).toBe(true) + expect( + await fs.pathExists(path.join(fixturePath, "custom/ui/card.tsx")) + ).toBe(true) + expect( + await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx")) + ).toBe(false) + expect( + await fs.pathExists(path.join(fixturePath, "components/ui/card.tsx")) + ).toBe(false) + }) + it("should add at-property", async () => { const fixturePath = await createFixtureTestDirectory("next-app-init") await npxShadcn(fixturePath, [