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:
shadcn
2026-05-05 14:55:47 +04:00
committed by GitHub
parent eb42ae25fd
commit 309d95017f
12 changed files with 662 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
allow alias placeholders in target for registry items

View File

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

View File

@@ -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.

View File

@@ -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": {

View File

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

View File

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

View File

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

View 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"
}
]
}

View 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"
}
]
}

View 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"
}
]
}

View 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"
}
]
}

View File

@@ -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"