feat(shadcn): handle nested paths for components (#6369)

* feat(shadcn): handle nested paths for components

* chore: add changeset
This commit is contained in:
shadcn
2025-01-15 18:01:44 +04:00
committed by GitHub
parent d5aa527f0b
commit f07c7ad5d0
5 changed files with 583 additions and 108 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
handle nested file path

View File

@@ -233,40 +233,6 @@ export async function fetchRegistry(paths: string[]) {
}
}
export function getRegistryItemFileTargetPath(
file: z.infer<typeof registryItemFileSchema>,
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<typeof registryItemSchema>["name"][],
config: Config

View File

@@ -56,6 +56,8 @@ export async function transformCssVars(
config: Config,
options: {
cleanupDefaultNextStyles?: boolean
} = {
cleanupDefaultNextStyles: false,
}
) {
options = {

View File

@@ -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<ReturnType<typeof getProjectInfo>>,
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<typeof registryItemFileSchema>,
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<typeof registryItemFileSchema>,
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("/")
}

View File

@@ -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",
},
"~/.env"
)
expect(targetDir).toBe("/foo/bar/.env")
})
test("should handle a home target even with a src directory", () => {
const targetDir = resolveTargetDir(
{
isSrcDir: true,
},
{
resolvedPaths: {
cwd: "/foo/bar",
},
},
"~/.env"
)
expect(targetDir).toBe("/foo/bar/.env")
})
test("should handle a simple target", () => {
const targetDir = resolveTargetDir(
{
resolvedPath: "/foo/bar/components/cards/example-card.tsx",
projectInfo: {
isSrcDir: false,
},
{
resolvedPaths: {
cwd: "/foo/bar",
},
},
{
description: "should use home target (~) when provided",
file: {
path: "hello-world/foo.json",
type: "registry:lib",
target: "~/foo.json",
},
"./components/ui/button.tsx"
)
expect(targetDir).toBe("/foo/bar/components/ui/button.tsx")
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 simple target with 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",
},
"./components/ui/button.tsx"
)
expect(targetDir).toBe("/foo/bar/src/components/ui/button.tsx")
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,
},
},
])("$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",
},
},
projectInfo
)
).toBe(resolvedPath)
})
test("should resolve registry:ui file types", () => {
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")
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)
})
})