diff --git a/.changeset/silent-rules-cover.md b/.changeset/silent-rules-cover.md new file mode 100644 index 00000000000..9e6ab3aa11b --- /dev/null +++ b/.changeset/silent-rules-cover.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +handle nested file path diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index 95e20ba4602..4ad54d218dc 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -233,40 +233,6 @@ export async function fetchRegistry(paths: string[]) { } } -export function getRegistryItemFileTargetPath( - file: z.infer, - config: Config, - override?: string -) { - if (override) { - return override - } - - if (file.type === "registry:ui") { - return config.resolvedPaths.ui - } - - if (file.type === "registry:lib") { - return config.resolvedPaths.lib - } - - if (file.type === "registry:block" || file.type === "registry:component") { - return config.resolvedPaths.components - } - - if (file.type === "registry:hook") { - return config.resolvedPaths.hooks - } - - // TODO: we put this in components for now. - // We should move this to pages as per framework. - if (file.type === "registry:page") { - return config.resolvedPaths.components - } - - return config.resolvedPaths.components -} - export async function registryResolveItemsTree( names: z.infer["name"][], config: Config diff --git a/packages/shadcn/src/utils/updaters/update-css-vars.ts b/packages/shadcn/src/utils/updaters/update-css-vars.ts index 73fd2cebebb..f362961dd4a 100644 --- a/packages/shadcn/src/utils/updaters/update-css-vars.ts +++ b/packages/shadcn/src/utils/updaters/update-css-vars.ts @@ -56,6 +56,8 @@ export async function transformCssVars( config: Config, options: { cleanupDefaultNextStyles?: boolean + } = { + cleanupDefaultNextStyles: false, } ) { options = { diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index f91921989d0..2e1df1f022d 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -1,10 +1,7 @@ import { existsSync, promises as fs } from "fs" import path, { basename } from "path" -import { - getRegistryBaseColor, - getRegistryItemFileTargetPath, -} from "@/src/registry/api" -import { RegistryItem } from "@/src/registry/schema" +import { getRegistryBaseColor } from "@/src/registry/api" +import { RegistryItem, registryItemFileSchema } from "@/src/registry/schema" import { Config } from "@/src/utils/get-config" import { getProjectInfo } from "@/src/utils/get-project-info" import { highlighter } from "@/src/utils/highlighter" @@ -17,19 +14,7 @@ import { transformImport } from "@/src/utils/transformers/transform-import" import { transformRsc } from "@/src/utils/transformers/transform-rsc" import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix" import prompts from "prompts" - -export function resolveTargetDir( - projectInfo: Awaited>, - config: Config, - target: string -) { - if (target.startsWith("~/")) { - return path.join(config.resolvedPaths.cwd, target.replace("~/", "")) - } - return projectInfo?.isSrcDir - ? path.join(config.resolvedPaths.cwd, "src", target) - : path.join(config.resolvedPaths.cwd, target) -} +import { z } from "zod" export async function updateFiles( files: RegistryItem["files"], @@ -74,14 +59,15 @@ export async function updateFiles( continue } - let targetDir = getRegistryItemFileTargetPath(file, config) + let filePath = resolveFilePath(file, config, { + isSrcDir: projectInfo?.isSrcDir, + commonRoot: findCommonRoot( + files.map((f) => f.path), + file.path + ), + }) const fileName = basename(file.path) - let filePath = path.join(targetDir, fileName) - - if (file.target) { - filePath = resolveTargetDir(projectInfo, config, file.target) - targetDir = path.dirname(filePath) - } + const targetDir = path.dirname(filePath) if (!config.tsx) { filePath = filePath.replace(/\.tsx?$/, (match) => @@ -210,3 +196,113 @@ export async function updateFiles( filesSkipped, } } + +export function resolveFilePath( + file: z.infer, + config: Config, + options: { + isSrcDir?: boolean + commonRoot?: string + } +) { + if (file.target) { + if (file.target.startsWith("~/")) { + return path.join(config.resolvedPaths.cwd, file.target.replace("~/", "")) + } + + return options.isSrcDir + ? path.join( + config.resolvedPaths.cwd, + "src", + file.target.replace("src/", "") + ) + : path.join(config.resolvedPaths.cwd, file.target.replace("src/", "")) + } + + const targetDir = resolveFileTargetDirectory(file, config) + + const relativePath = resolveNestedFilePath(file.path, targetDir) + return path.join(targetDir, relativePath) +} + +function resolveFileTargetDirectory( + file: z.infer, + config: Config +) { + if (file.type === "registry:ui") { + return config.resolvedPaths.ui + } + + if (file.type === "registry:lib") { + return config.resolvedPaths.lib + } + + if (file.type === "registry:block" || file.type === "registry:component") { + return config.resolvedPaths.components + } + + if (file.type === "registry:hook") { + return config.resolvedPaths.hooks + } + + return config.resolvedPaths.components +} + +export function findCommonRoot(paths: string[], needle: string): string { + // Remove leading slashes for consistent handling + const normalizedPaths = paths.map((p) => p.replace(/^\//, "")) + const normalizedNeedle = needle.replace(/^\//, "") + + // Get the directory path of the needle by removing the file name + const needleDir = normalizedNeedle.split("/").slice(0, -1).join("/") + + // If needle is at root level, return empty string + if (!needleDir) { + return "" + } + + // Split the needle directory into segments + const needleSegments = needleDir.split("/") + + // Start from the full path and work backwards + for (let i = needleSegments.length; i > 0; i--) { + const testPath = needleSegments.slice(0, i).join("/") + // Check if this is a common root by verifying if any other paths start with it + const hasRelatedPaths = normalizedPaths.some( + (path) => path !== normalizedNeedle && path.startsWith(testPath + "/") + ) + if (hasRelatedPaths) { + return "/" + testPath // Add leading slash back for the result + } + } + + // If no common root found with other files, return the parent directory of the needle + return "/" + needleDir // Add leading slash back for the result +} + +export function resolveNestedFilePath( + filePath: string, + targetDir: string +): string { + // Normalize paths by removing leading/trailing slashes + const normalizedFilePath = filePath.replace(/^\/|\/$/g, "") + const normalizedTargetDir = targetDir.replace(/^\/|\/$/g, "") + + // Split paths into segments + const fileSegments = normalizedFilePath.split("/") + const targetSegments = normalizedTargetDir.split("/") + + // Find the last matching segment from targetDir in filePath + const lastTargetSegment = targetSegments[targetSegments.length - 1] + const commonDirIndex = fileSegments.findIndex( + (segment) => segment === lastTargetSegment + ) + + if (commonDirIndex === -1) { + // Return just the filename if no common directory is found + return fileSegments[fileSegments.length - 1] + } + + // Return everything after the common directory + return fileSegments.slice(commonDirIndex + 1).join("/") +} diff --git a/packages/shadcn/test/utils/updaters/update-files.test.ts b/packages/shadcn/test/utils/updaters/update-files.test.ts index 4d12fcb841e..c605c357896 100644 --- a/packages/shadcn/test/utils/updaters/update-files.test.ts +++ b/packages/shadcn/test/utils/updaters/update-files.test.ts @@ -1,65 +1,471 @@ import { describe, expect, test } from "vitest" -import { resolveTargetDir } from "../../../src/utils/updaters/update-files" +import { + findCommonRoot, + resolveFilePath, + resolveNestedFilePath, +} from "../../../src/utils/updaters/update-files" -describe("resolveTargetDir", () => { - test("should handle a home target without a src directory", () => { - const targetDir = resolveTargetDir( - { +describe("resolveFilePath", () => { + test.each([ + { + description: "should use target when provided", + file: { + path: "hello-world/ui/button.tsx", + type: "registry:ui", + target: "ui/button.tsx", + }, + resolvedPath: "/foo/bar/ui/button.tsx", + projectInfo: { isSrcDir: false, }, - { - resolvedPaths: { - cwd: "/foo/bar", - }, + }, + { + description: "should use nested target when provided", + file: { + path: "hello-world/components/example-card.tsx", + type: "registry:component", + target: "components/cards/example-card.tsx", + }, + resolvedPath: "/foo/bar/components/cards/example-card.tsx", + projectInfo: { + isSrcDir: false, + }, + }, + { + description: "should use home target (~) when provided", + file: { + path: "hello-world/foo.json", + type: "registry:lib", + target: "~/foo.json", }, - "~/.env" - ) - expect(targetDir).toBe("/foo/bar/.env") + resolvedPath: "/foo/bar/foo.json", + projectInfo: { + isSrcDir: false, + }, + }, + ])("$description", ({ file, resolvedPath, projectInfo }) => { + expect( + resolveFilePath( + file, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + hooks: "/foo/bar/hooks", + }, + }, + projectInfo + ) + ).toBe(resolvedPath) }) - test("should handle a home target even with a src directory", () => { - const targetDir = resolveTargetDir( - { + test.each([ + { + description: "should use src directory when provided", + file: { + path: "hello-world/ui/button.tsx", + type: "registry:ui", + target: "design-system/ui/button.tsx", + }, + resolvedPath: "/foo/bar/src/design-system/ui/button.tsx", + projectInfo: { isSrcDir: true, }, - { - resolvedPaths: { - cwd: "/foo/bar", - }, + }, + { + description: "should NOT use src directory for root files", + file: { + path: "hello-world/.env", + type: "registry:lib", + target: "~/.env", }, - "~/.env" - ) - expect(targetDir).toBe("/foo/bar/.env") - }) - - test("should handle a simple target", () => { - const targetDir = resolveTargetDir( - { + resolvedPath: "/foo/bar/.env", + projectInfo: { + isSrcDir: true, + }, + }, + { + description: "should use src directory when isSrcDir is true", + file: { + path: "hello-world/lib/foo.ts", + type: "registry:lib", + target: "lib/foo.ts", + }, + resolvedPath: "/foo/bar/src/lib/foo.ts", + projectInfo: { + isSrcDir: true, + }, + }, + { + description: "should strip src directory when isSrcDir is false", + file: { + path: "hello-world/path/to/bar/baz.ts", + type: "registry:lib", + target: "src/path/to/bar/baz.ts", + }, + resolvedPath: "/foo/bar/path/to/bar/baz.ts", + projectInfo: { isSrcDir: false, }, - { - resolvedPaths: { - cwd: "/foo/bar", + }, + ])("$description", ({ file, resolvedPath, projectInfo }) => { + expect( + resolveFilePath( + file, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/src/components", + ui: "/foo/bar/src/primitives", + lib: "/foo/bar/src/lib", + hooks: "/foo/bar/src/hooks", + }, }, - }, - "./components/ui/button.tsx" - ) - expect(targetDir).toBe("/foo/bar/components/ui/button.tsx") + projectInfo + ) + ).toBe(resolvedPath) }) - test("should handle a simple target with src directory", () => { - const targetDir = resolveTargetDir( - { - isSrcDir: true, - }, - { - resolvedPaths: { - cwd: "/foo/bar", + test("should resolve registry:ui file types", () => { + expect( + resolveFilePath( + { + path: "hello-world/ui/button.tsx", + type: "registry:ui", }, - }, - "./components/ui/button.tsx" - ) - expect(targetDir).toBe("/foo/bar/src/components/ui/button.tsx") + { + 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") + + expect( + resolveFilePath( + { + path: "hello-world/ui/button.tsx", + type: "registry:ui", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/src/components", + ui: "/foo/bar/src/primitives", + lib: "/foo/bar/src/lib", + hooks: "/foo/bar/src/hooks", + }, + }, + { + isSrcDir: true, + } + ) + ).toBe("/foo/bar/src/primitives/button.tsx") + }) + + test("should resolve registry:component and registry:block file types", () => { + expect( + resolveFilePath( + { + path: "hello-world/components/example-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, + } + ) + ).toBe("/foo/bar/components/example-card.tsx") + + expect( + resolveFilePath( + { + path: "hello-world/components/example-card.tsx", + type: "registry:block", + }, + { + 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/example-card.tsx") + + expect( + resolveFilePath( + { + path: "hello-world/components/example-card.tsx", + type: "registry:component", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/src/components", + ui: "/foo/bar/src/primitives", + lib: "/foo/bar/src/lib", + hooks: "/foo/bar/src/hooks", + }, + }, + { + isSrcDir: true, + } + ) + ).toBe("/foo/bar/src/components/example-card.tsx") + + expect( + resolveFilePath( + { + path: "hello-world/components/example-card.tsx", + type: "registry:block", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/src/components", + ui: "/foo/bar/src/primitives", + lib: "/foo/bar/src/lib", + hooks: "/foo/bar/src/hooks", + }, + }, + { + isSrcDir: true, + } + ) + ).toBe("/foo/bar/src/components/example-card.tsx") + }) + + test("should resolve registry:lib file types", () => { + expect( + resolveFilePath( + { + path: "hello-world/lib/foo.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, + } + ) + ).toBe("/foo/bar/lib/foo.ts") + + expect( + resolveFilePath( + { + path: "hello-world/lib/foo.ts", + type: "registry:lib", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/src/components", + ui: "/foo/bar/src/primitives", + lib: "/foo/bar/src/lib", + hooks: "/foo/bar/src/hooks", + }, + }, + { + isSrcDir: true, + } + ) + ).toBe("/foo/bar/src/lib/foo.ts") + }) + + test("should resolve registry:hook file types", () => { + expect( + resolveFilePath( + { + path: "hello-world/hooks/use-foo.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, + } + ) + ).toBe("/foo/bar/hooks/use-foo.ts") + + expect( + resolveFilePath( + { + path: "hello-world/hooks/use-foo.ts", + type: "registry:hook", + }, + { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/src/components", + ui: "/foo/bar/src/primitives", + lib: "/foo/bar/src/lib", + hooks: "/foo/bar/src/hooks", + }, + }, + { + isSrcDir: true, + } + ) + ).toBe("/foo/bar/src/hooks/use-foo.ts") + }) + + test("should resolve nested files", () => { + expect( + resolveFilePath( + { + path: "hello-world/components/path/to/example-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, + } + ) + ).toBe("/foo/bar/components/path/to/example-card.tsx") + + expect( + resolveFilePath( + { + path: "hello-world/design-system/primitives/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") + }) +}) + +describe("findCommonRoot", () => { + test.each([ + { + description: "should find the common root of sibling files", + paths: ["/foo/bar/baz/qux", "/foo/bar/baz/quux"], + needle: "/foo/bar/baz/qux", + expected: "/foo/bar/baz", + }, + { + description: "should find common root with nested structures", + paths: [ + "/app/components/header/nav.tsx", + "/app/components/header/logo.tsx", + "/app/components/header/menu/item.tsx", + ], + needle: "/app/components/header/nav.tsx", + expected: "/app/components/header", + }, + { + description: "should handle single file in paths", + paths: ["/foo/bar/baz/single.tsx"], + needle: "/foo/bar/baz/single.tsx", + expected: "/foo/bar/baz", + }, + { + description: "should handle root level files", + paths: ["root.tsx", "config.ts", "package.json"], + needle: "root.tsx", + expected: "", + }, + { + description: "should handle unrelated paths", + paths: ["/foo/bar/baz", "/completely/different/path"], + needle: "/foo/bar/baz", + expected: "/foo/bar", + }, + ])("$description", ({ paths, needle, expected }) => { + expect(findCommonRoot(paths, needle)).toBe(expected) + }) +}) + +describe("resolveNestedFilePath", () => { + test.each([ + { + description: "should resolve path after common components directory", + filePath: "hello-world/components/path/to/example-card.tsx", + targetDir: "/foo/bar/components", + expected: "path/to/example-card.tsx", + }, + { + description: "should handle different directory depths", + filePath: "/foo-bar/components/ui/button.tsx", + targetDir: "/src/ui", + expected: "button.tsx", + }, + { + description: "should handle nested component paths", + filePath: "blocks/sidebar/components/nav/item.tsx", + targetDir: "/app/components", + expected: "nav/item.tsx", + }, + { + description: "should return the file path if no common directory", + filePath: "something/else/file.tsx", + targetDir: "/foo/bar/components", + expected: "file.tsx", + }, + { + description: "should handle paths with multiple common directories", + filePath: "ui/shared/components/utils/button.tsx", + targetDir: "/src/components/utils", + expected: "button.tsx", + }, + ])("$description", ({ filePath, targetDir, expected }) => { + expect(resolveNestedFilePath(filePath, targetDir)).toBe(expected) }) })