mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat(shadcn): alias placeholders in target (#10528)
* feat: add support for package imports * fix * test(cli): surface add command failures * test(cli): remove stale pnpm pin from fixture * fix(cli): reject invalid package import targets * fix(cli): address package import review feedback * feat(shadcn): alias placeholders in target * docs: update docs for alias and examples * chore: remove lockfile drift * chore: clean up * fix(shadcn): route target aliases by workspace * docs(registry): document target subdirectories
This commit is contained in:
5
.changeset/stupid-fans-type.md
Normal file
5
.changeset/stupid-fans-type.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
allow alias placeholders in target for registry items
|
||||
@@ -309,6 +309,94 @@ A `registry:hook` item is a custom React hook.
|
||||
}
|
||||
```
|
||||
|
||||
## Target Placeholders
|
||||
|
||||
Use `files[].target` placeholders when a registry item should install files
|
||||
under the user's configured shadcn directories. The available placeholders are
|
||||
`@components/`, `@ui/`, `@lib/` and `@hooks/`.
|
||||
|
||||
The placeholders are resolved from `components.json`, so the same registry item
|
||||
works in projects using `@/`, custom TypeScript aliases, package imports or
|
||||
workspace package exports.
|
||||
|
||||
Anything after the placeholder is preserved. For example,
|
||||
`@ui/ai/prompt-input.tsx` installs under the user's configured `ui` directory
|
||||
at `ai/prompt-input.tsx`.
|
||||
|
||||
```json title="alias-child.json" showLineNumbers {9,15,21}
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "alias-child",
|
||||
"type": "registry:item",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/alias/target-alias-button.tsx",
|
||||
"type": "registry:ui",
|
||||
"target": "@ui/target-alias-button.tsx",
|
||||
"content": "..."
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/alias/target-alias-helper.ts",
|
||||
"type": "registry:lib",
|
||||
"target": "@lib/target-alias-helper.ts",
|
||||
"content": "..."
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/alias/prompt-input.tsx",
|
||||
"type": "registry:ui",
|
||||
"target": "@ui/ai/prompt-input.tsx",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Registry dependencies can use target placeholders too. In the following example,
|
||||
the child item installs a UI component and a helper, while the parent item
|
||||
installs an app component and a hook.
|
||||
|
||||
```json title="alias-parent.json" showLineNumbers {7,13}
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "alias-parent",
|
||||
"type": "registry:item",
|
||||
"registryDependencies": ["https://example.com/r/alias-child.json"],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/alias/target-alias-panel.tsx",
|
||||
"type": "registry:component",
|
||||
"target": "@components/target-alias-panel.tsx",
|
||||
"content": "..."
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/alias/use-target-alias.ts",
|
||||
"type": "registry:hook",
|
||||
"target": "@hooks/use-target-alias.ts",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `target` controls where the file is written, even when it differs from the
|
||||
file `type`.
|
||||
|
||||
```json title="type-mismatch.json" showLineNumbers {9}
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "type-mismatch",
|
||||
"type": "registry:item",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/example/format-date.ts",
|
||||
"type": "registry:ui",
|
||||
"target": "@lib/format-date.ts",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## registry:font
|
||||
|
||||
### Custom font
|
||||
|
||||
@@ -226,6 +226,80 @@ By default, the `shadcn` cli will read a project's `components.json` file to det
|
||||
|
||||
Use `~` to refer to the root of the project e.g `~/foo.config.js`.
|
||||
|
||||
You can also use registry target placeholders to place files under the
|
||||
directories configured by the user's `components.json`. These placeholders are
|
||||
only supported at the start of `target` and are independent of the project's
|
||||
import prefix. For example, `@ui/button.tsx` works whether the project imports
|
||||
components with `@/`, `#`, package imports or workspace exports.
|
||||
|
||||
| Placeholder | Resolves to |
|
||||
| -------------- | -------------------- |
|
||||
| `@components/` | `aliases.components` |
|
||||
| `@ui/` | `aliases.ui` |
|
||||
| `@lib/` | `aliases.lib` |
|
||||
| `@hooks/` | `aliases.hooks` |
|
||||
|
||||
Use these placeholders when you want a registry item to install into the
|
||||
project's configured shadcn directories without hardcoding `components`, `src`
|
||||
or workspace package paths. Anything after the placeholder is preserved, so
|
||||
`@ui/ai/prompt-input.tsx` installs under the user's configured `ui` directory
|
||||
at `ai/prompt-input.tsx`.
|
||||
|
||||
```json title="registry-item.json" showLineNumbers
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/example/button.tsx",
|
||||
"type": "registry:ui",
|
||||
"target": "@ui/button.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/example/prompt-input.tsx",
|
||||
"type": "registry:ui",
|
||||
"target": "@ui/ai/prompt-input.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/example/card.tsx",
|
||||
"type": "registry:component",
|
||||
"target": "@components/card.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/example/helper.ts",
|
||||
"type": "registry:lib",
|
||||
"target": "@lib/helper.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/new-york/example/use-demo.ts",
|
||||
"type": "registry:hook",
|
||||
"target": "@hooks/use-demo.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `target` property decides where the file is written. It can point to a
|
||||
different shadcn directory than the file `type`.
|
||||
|
||||
```json title="registry-item.json" showLineNumbers
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/example/format-date.ts",
|
||||
"type": "registry:ui",
|
||||
"target": "@lib/format-date.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Unknown placeholders are treated as regular target paths. For example,
|
||||
`@foo/bar.ts` is written as `foo/bar.ts`. Embedded placeholders such as
|
||||
`components/@ui/button.tsx` are also treated as regular paths.
|
||||
|
||||
<Callout>
|
||||
`@utils/` is not supported because `utils` points to a file, not a directory.
|
||||
</Callout>
|
||||
|
||||
### tailwind
|
||||
|
||||
**DEPRECATED:** Use `cssVars.theme` instead for Tailwind v4 projects.
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "The target path of the file. This is the path to the file in the project."
|
||||
"description": "The target path of the file. This is the path to the file in the project. Supports registry target placeholders @components/, @ui/, @lib/, and @hooks/, which resolve to the corresponding aliases configured in components.json. These placeholders are independent of the project's import prefix."
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
|
||||
@@ -254,36 +254,52 @@ async function addWorkspaceComponents(
|
||||
silent: true,
|
||||
})
|
||||
|
||||
// 5. Group files by their type to determine target config and update files.
|
||||
const filesByType = new Map<string, typeof tree.files>()
|
||||
|
||||
for (const file of tree.files ?? []) {
|
||||
const type = file.type || "registry:ui"
|
||||
if (!filesByType.has(type)) {
|
||||
filesByType.set(type, [])
|
||||
}
|
||||
filesByType.get(type)!.push(file)
|
||||
}
|
||||
|
||||
const FILE_TYPE_TO_CONFIG_KEY: Record<string, string> = {
|
||||
// 5. Group files by their target config and update files.
|
||||
const TARGET_ALIAS_KEYS = ["components", "ui", "lib", "hooks"] as const
|
||||
type TargetAliasKey = (typeof TARGET_ALIAS_KEYS)[number]
|
||||
const filesByTarget = new Map<TargetAliasKey, typeof tree.files>()
|
||||
const FILE_TYPE_TO_CONFIG_KEY: Record<string, TargetAliasKey> = {
|
||||
"registry:ui": "ui",
|
||||
"registry:hook": "hooks",
|
||||
"registry:lib": "lib",
|
||||
}
|
||||
const getTargetConfigForType = (type: string) => {
|
||||
const configKey = FILE_TYPE_TO_CONFIG_KEY[type]
|
||||
const isTargetAliasKey = (key: string): key is TargetAliasKey => {
|
||||
return TARGET_ALIAS_KEYS.includes(key as TargetAliasKey)
|
||||
}
|
||||
const getTargetAliasKey = (target?: string) => {
|
||||
const match = target?.match(/^@([^/]+)\//)
|
||||
return match && isTargetAliasKey(match[1]) ? match[1] : null
|
||||
}
|
||||
const getTargetConfigKeyForFile = (
|
||||
file: z.infer<typeof registryItemFileSchema>
|
||||
) => {
|
||||
return (
|
||||
getTargetAliasKey(file.target) ??
|
||||
FILE_TYPE_TO_CONFIG_KEY[file.type || "registry:ui"] ??
|
||||
"components"
|
||||
)
|
||||
}
|
||||
const getTargetConfigForKey = (configKey: TargetAliasKey) => {
|
||||
return configKey && workspaceConfig[configKey]
|
||||
? workspaceConfig[configKey]
|
||||
: config
|
||||
}
|
||||
|
||||
// Process each type of component with its appropriate target config.
|
||||
for (const type of Array.from(filesByType.keys())) {
|
||||
const typeFiles = filesByType.get(type)!
|
||||
const targetConfig = getTargetConfigForType(type)
|
||||
for (const file of tree.files ?? []) {
|
||||
const targetKey = getTargetConfigKeyForFile(file)
|
||||
if (!filesByTarget.has(targetKey)) {
|
||||
filesByTarget.set(targetKey, [])
|
||||
}
|
||||
filesByTarget.get(targetKey)!.push(file)
|
||||
}
|
||||
|
||||
// Process each target config with its appropriate workspace config.
|
||||
for (const targetKey of Array.from(filesByTarget.keys())) {
|
||||
const targetFiles = filesByTarget.get(targetKey)!
|
||||
const targetConfig = getTargetConfigForKey(targetKey)
|
||||
const plannedFiles = (tree.files ?? []).filter((file) => {
|
||||
const fileTargetConfig = getTargetConfigForType(
|
||||
file.type || "registry:ui"
|
||||
const fileTargetConfig = getTargetConfigForKey(
|
||||
getTargetConfigKeyForFile(file)
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -301,8 +317,8 @@ async function addWorkspaceComponents(
|
||||
targetConfig.resolvedPaths.cwd
|
||||
)) ?? targetConfig.resolvedPaths.cwd
|
||||
|
||||
// Update files for this type.
|
||||
const files = await updateFiles(typeFiles, targetConfig, {
|
||||
// Update files for this target config.
|
||||
const files = await updateFiles(targetFiles, targetConfig, {
|
||||
overwrite: options.overwrite,
|
||||
silent: true,
|
||||
rootSpinner,
|
||||
|
||||
@@ -40,6 +40,9 @@ import { z } from "zod"
|
||||
|
||||
const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]
|
||||
const NON_ALIAS_RESOLVED_PATH_KEYS = new Set(["tailwindConfig", "tailwindCss"])
|
||||
const TARGET_ALIAS_KEYS = ["components", "ui", "lib", "hooks"] as const
|
||||
|
||||
type TargetAliasKey = (typeof TARGET_ALIAS_KEYS)[number]
|
||||
|
||||
export async function updateFiles(
|
||||
files: RegistryItem["files"],
|
||||
@@ -412,6 +415,15 @@ export function resolveFilePath(
|
||||
}
|
||||
|
||||
let target = file.target
|
||||
const aliasTarget = resolveAliasTarget(target, config)
|
||||
|
||||
if (aliasTarget?.resolvedPath) {
|
||||
return aliasTarget.resolvedPath
|
||||
}
|
||||
|
||||
if (aliasTarget?.target) {
|
||||
target = aliasTarget.target
|
||||
}
|
||||
|
||||
if (file.type === "registry:page") {
|
||||
target = resolvePageTarget(target, options.framework)
|
||||
@@ -431,6 +443,42 @@ export function resolveFilePath(
|
||||
return path.join(targetDir, relativePath)
|
||||
}
|
||||
|
||||
function resolveAliasTarget(target: string, config: Config) {
|
||||
const match = target.match(/^@([^/]+)\/(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [, aliasKey, targetPath] = match
|
||||
|
||||
if (!isTargetAliasKey(aliasKey)) {
|
||||
return {
|
||||
target: `${aliasKey}/${targetPath}`,
|
||||
}
|
||||
}
|
||||
|
||||
const aliasRoot = path.resolve(config.resolvedPaths[aliasKey])
|
||||
const resolvedPath = path.resolve(aliasRoot, targetPath)
|
||||
|
||||
if (
|
||||
resolvedPath !== aliasRoot &&
|
||||
!resolvedPath.startsWith(`${aliasRoot}${path.sep}`)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid target path "${target}". Target paths using @${aliasKey}/ must stay within the ${aliasKey} alias root.`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedPath,
|
||||
}
|
||||
}
|
||||
|
||||
function isTargetAliasKey(aliasKey: string): aliasKey is TargetAliasKey {
|
||||
return TARGET_ALIAS_KEYS.includes(aliasKey as TargetAliasKey)
|
||||
}
|
||||
|
||||
function resolveFileTargetDirectory(
|
||||
file: z.infer<typeof registryItemFileSchema>,
|
||||
config: Config
|
||||
|
||||
@@ -109,6 +109,178 @@ describe("resolveFilePath", () => {
|
||||
).toBe(resolvedPath)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: "should resolve @components target aliases",
|
||||
target: "@components/charts/pie.tsx",
|
||||
resolvedPath: "/foo/bar/components/charts/pie.tsx",
|
||||
},
|
||||
{
|
||||
description: "should resolve @ui target aliases",
|
||||
target: "@ui/button.tsx",
|
||||
resolvedPath: "/foo/bar/components/ui/button.tsx",
|
||||
},
|
||||
{
|
||||
description: "should resolve @lib target aliases",
|
||||
target: "@lib/format.ts",
|
||||
resolvedPath: "/foo/bar/lib/format.ts",
|
||||
},
|
||||
{
|
||||
description: "should resolve @hooks target aliases",
|
||||
target: "@hooks/use-theme.ts",
|
||||
resolvedPath: "/foo/bar/hooks/use-theme.ts",
|
||||
},
|
||||
])("$description", ({ target, resolvedPath }) => {
|
||||
expect(
|
||||
resolveFilePath(
|
||||
{
|
||||
path: "hello-world/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
target,
|
||||
},
|
||||
{
|
||||
resolvedPaths: {
|
||||
cwd: "/foo/bar",
|
||||
components: "/foo/bar/components",
|
||||
ui: "/foo/bar/components/ui",
|
||||
lib: "/foo/bar/lib",
|
||||
hooks: "/foo/bar/hooks",
|
||||
},
|
||||
},
|
||||
{
|
||||
isSrcDir: true,
|
||||
}
|
||||
)
|
||||
).toBe(resolvedPath)
|
||||
})
|
||||
|
||||
test("should resolve target aliases with package import backed aliases", () => {
|
||||
expect(
|
||||
resolveFilePath(
|
||||
{
|
||||
path: "hello-world/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
target: "@ui/button.tsx",
|
||||
},
|
||||
{
|
||||
aliases: {
|
||||
components: "#components",
|
||||
ui: "#components/ui",
|
||||
lib: "#lib",
|
||||
hooks: "#hooks",
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd: "/foo/bar",
|
||||
components: "/foo/bar/src/components",
|
||||
ui: "/foo/bar/src/components/ui",
|
||||
lib: "/foo/bar/src/lib",
|
||||
hooks: "/foo/bar/src/hooks",
|
||||
},
|
||||
},
|
||||
{
|
||||
isSrcDir: false,
|
||||
}
|
||||
)
|
||||
).toBe("/foo/bar/src/components/ui/button.tsx")
|
||||
})
|
||||
|
||||
test("should fall back to normal target resolution for unknown aliases", () => {
|
||||
expect(
|
||||
resolveFilePath(
|
||||
{
|
||||
path: "hello-world/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
target: "@foo/bar.ts",
|
||||
},
|
||||
{
|
||||
resolvedPaths: {
|
||||
cwd: "/foo/bar",
|
||||
components: "/foo/bar/components",
|
||||
ui: "/foo/bar/components/ui",
|
||||
lib: "/foo/bar/lib",
|
||||
hooks: "/foo/bar/hooks",
|
||||
},
|
||||
},
|
||||
{
|
||||
isSrcDir: true,
|
||||
}
|
||||
)
|
||||
).toBe("/foo/bar/src/foo/bar.ts")
|
||||
})
|
||||
|
||||
test("should not resolve embedded alias-like path segments", () => {
|
||||
expect(
|
||||
resolveFilePath(
|
||||
{
|
||||
path: "hello-world/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
target: "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")
|
||||
})
|
||||
|
||||
test("should bypass page target mapping for target aliases", () => {
|
||||
expect(
|
||||
resolveFilePath(
|
||||
{
|
||||
path: "hello-world/app/login/page.tsx",
|
||||
type: "registry:page",
|
||||
target: "@ui/page.tsx",
|
||||
},
|
||||
{
|
||||
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,
|
||||
framework: "next-pages",
|
||||
}
|
||||
)
|
||||
).toBe("/foo/bar/src/primitives/page.tsx")
|
||||
})
|
||||
|
||||
test("should reject target aliases that escape the alias root", () => {
|
||||
expect(() =>
|
||||
resolveFilePath(
|
||||
{
|
||||
path: "hello-world/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
target: "@ui/../../page.tsx",
|
||||
},
|
||||
{
|
||||
resolvedPaths: {
|
||||
cwd: "/foo/bar",
|
||||
components: "/foo/bar/components",
|
||||
ui: "/foo/bar/components/ui",
|
||||
lib: "/foo/bar/lib",
|
||||
hooks: "/foo/bar/hooks",
|
||||
},
|
||||
},
|
||||
{
|
||||
isSrcDir: false,
|
||||
}
|
||||
)
|
||||
).toThrow('Invalid target path "@ui/../../page.tsx".')
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: "should use src directory when provided",
|
||||
|
||||
19
packages/tests/fixtures/registry/example-target-alias-child.json
vendored
Normal file
19
packages/tests/fixtures/registry/example-target-alias-child.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "example-target-alias-child",
|
||||
"type": "registry:component",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/example-target-alias-child/ui/dependency-button.tsx",
|
||||
"type": "registry:ui",
|
||||
"content": "export function DependencyButton() {\n return <button>Dependency Button</button>\n}\n",
|
||||
"target": "@ui/dependency-button.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/example-target-alias-child/lib/dependency-helper.ts",
|
||||
"type": "registry:lib",
|
||||
"content": "export function dependencyHelper() {\n return \"dependency-helper\"\n}\n",
|
||||
"target": "@lib/dependency-helper.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
packages/tests/fixtures/registry/example-target-alias-parent.json
vendored
Normal file
22
packages/tests/fixtures/registry/example-target-alias-parent.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "example-target-alias-parent",
|
||||
"type": "registry:component",
|
||||
"registryDependencies": [
|
||||
"../../fixtures/registry/example-target-alias-child.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/example-target-alias-parent/components/dependency-panel.tsx",
|
||||
"type": "registry:component",
|
||||
"content": "export function DependencyPanel() {\n return <div>Dependency Panel</div>\n}\n",
|
||||
"target": "@components/dependency-panel.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/example-target-alias-parent/hooks/use-dependency.ts",
|
||||
"type": "registry:hook",
|
||||
"content": "export function useDependency() {\n return \"dependency\"\n}\n",
|
||||
"target": "@hooks/use-dependency.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
packages/tests/fixtures/registry/example-target-alias-type-mismatch.json
vendored
Normal file
13
packages/tests/fixtures/registry/example-target-alias-type-mismatch.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "example-target-alias-type-mismatch",
|
||||
"type": "registry:component",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/example-target-alias-type-mismatch/ui/target-from-ui-type.ts",
|
||||
"type": "registry:ui",
|
||||
"content": "export function targetFromUiType() {\n return \"target-from-ui-type\"\n}\n",
|
||||
"target": "@lib/target-from-ui-type.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
31
packages/tests/fixtures/registry/example-target-aliases.json
vendored
Normal file
31
packages/tests/fixtures/registry/example-target-aliases.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "example-target-aliases",
|
||||
"type": "registry:component",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/example-target-aliases/ui/target-button.tsx",
|
||||
"type": "registry:ui",
|
||||
"content": "export function TargetButton() {\n return <button>Target Button</button>\n}\n",
|
||||
"target": "@ui/target-button.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/example-target-aliases/components/target-panel.tsx",
|
||||
"type": "registry:component",
|
||||
"content": "export function TargetPanel() {\n return <div>Target Panel</div>\n}\n",
|
||||
"target": "@components/target-panel.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/example-target-aliases/lib/target-helper.ts",
|
||||
"type": "registry:lib",
|
||||
"content": "export function targetHelper() {\n return \"target-helper\"\n}\n",
|
||||
"target": "@lib/target-helper.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/example-target-aliases/hooks/use-target.ts",
|
||||
"type": "registry:hook",
|
||||
"content": "export function useTarget() {\n return \"target\"\n}\n",
|
||||
"target": "@hooks/use-target.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -258,6 +258,74 @@ describe("shadcn add", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("should add item with registry target aliases", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"add",
|
||||
"../../fixtures/registry/example-target-aliases.json",
|
||||
])
|
||||
|
||||
expectCommandSuccess(result)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "components/ui/target-button.tsx")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "components/target-panel.tsx"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "lib/target-helper.ts"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "hooks/use-target.ts"))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should add registryDependencies with registry target aliases", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"add",
|
||||
"../../fixtures/registry/example-target-alias-parent.json",
|
||||
])
|
||||
|
||||
expectCommandSuccess(result)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "components/ui/dependency-button.tsx")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "lib/dependency-helper.ts"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "components/dependency-panel.tsx")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "hooks/use-dependency.ts"))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should prefer registry target aliases over the file type", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const result = await npxShadcn(fixturePath, [
|
||||
"add",
|
||||
"../../fixtures/registry/example-target-alias-type-mismatch.json",
|
||||
])
|
||||
|
||||
expectCommandSuccess(result)
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "lib/target-from-ui-type.ts"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "components/ui/target-from-ui-type.ts")
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("should add item with envVars", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults"])
|
||||
@@ -324,6 +392,89 @@ describe("shadcn add", () => {
|
||||
expect(buttonContent).toContain('import { cn } from "#lib/utils.ts"')
|
||||
}, 300000)
|
||||
|
||||
it("should add monorepo item with registry target aliases and package imports", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory(
|
||||
"vite-monorepo-imports"
|
||||
)
|
||||
|
||||
const result = await npxShadcn(
|
||||
fixturePath,
|
||||
[
|
||||
"add",
|
||||
"../../fixtures/registry/example-target-aliases.json",
|
||||
"-c",
|
||||
"apps/web",
|
||||
"--yes",
|
||||
],
|
||||
{ timeout: 300000 }
|
||||
)
|
||||
|
||||
expectCommandSuccess(result)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "packages/ui/src/components/target-button.tsx")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "apps/web/src/components/target-panel.tsx")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "apps/web/src/lib/target-helper.ts")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "apps/web/src/hooks/use-target.ts")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "apps/web/src/components/ui/target-button.tsx")
|
||||
)
|
||||
).toBe(false)
|
||||
}, 300000)
|
||||
|
||||
it("should prefer registry target aliases over the file type in monorepos", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory(
|
||||
"vite-monorepo-imports"
|
||||
)
|
||||
|
||||
const result = await npxShadcn(
|
||||
fixturePath,
|
||||
[
|
||||
"add",
|
||||
"../../fixtures/registry/example-target-alias-type-mismatch.json",
|
||||
"-c",
|
||||
"apps/web",
|
||||
"--yes",
|
||||
],
|
||||
{ timeout: 300000 }
|
||||
)
|
||||
|
||||
expectCommandSuccess(result)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "apps/web/src/lib/target-from-ui-type.ts")
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(fixturePath, "packages/ui/src/lib/target-from-ui-type.ts")
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
path.join(
|
||||
fixturePath,
|
||||
"packages/ui/src/components/target-from-ui-type.ts"
|
||||
)
|
||||
)
|
||||
).toBe(false)
|
||||
}, 300000)
|
||||
|
||||
it("should preview monorepo adds without writing files", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory(
|
||||
"vite-monorepo-imports"
|
||||
|
||||
Reference in New Issue
Block a user