feat(shadcn): allow path to override targets (#8452)

This commit is contained in:
shadcn
2025-10-15 10:37:58 +04:00
committed by GitHub
parent adb66f4d43
commit e8674ee848
4 changed files with 459 additions and 2 deletions

View File

@@ -36,6 +36,7 @@ export async function addComponents(
isNewProject?: boolean
baseStyle?: boolean
registryHeaders?: Record<string, Record<string, string>>
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(

View File

@@ -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<typeof spinner>
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("~/", ""))

View File

@@ -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 <button>Custom Button</button>
}`,
},
],
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 <button>Button</button>
}`,
},
{
path: "registry/default/ui/card.tsx",
type: "registry:ui",
content: `export function Card() {
return <div>Card</div>
}`,
},
],
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 <button>Button</button>
}`,
},
{
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", () => {

View File

@@ -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, [