mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-02 08:58:36 +00:00
feat(shadcn): allow path to override targets (#8452)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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("~/", ""))
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user