From 309d95017fce3936548c15d2ef827c84560cc45a Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 5 May 2026 14:55:47 +0400 Subject: [PATCH] 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 --- .changeset/stupid-fans-type.md | 5 + apps/v4/content/docs/registry/examples.mdx | 88 +++++++++ .../docs/registry/registry-item-json.mdx | 74 ++++++++ apps/v4/public/schema/registry-item.json | 2 +- packages/shadcn/src/utils/add-components.ts | 60 +++--- .../shadcn/src/utils/updaters/update-files.ts | 48 +++++ .../test/utils/updaters/update-files.test.ts | 172 ++++++++++++++++++ .../registry/example-target-alias-child.json | 19 ++ .../registry/example-target-alias-parent.json | 22 +++ .../example-target-alias-type-mismatch.json | 13 ++ .../registry/example-target-aliases.json | 31 ++++ packages/tests/src/tests/add.test.ts | 151 +++++++++++++++ 12 files changed, 662 insertions(+), 23 deletions(-) create mode 100644 .changeset/stupid-fans-type.md create mode 100644 packages/tests/fixtures/registry/example-target-alias-child.json create mode 100644 packages/tests/fixtures/registry/example-target-alias-parent.json create mode 100644 packages/tests/fixtures/registry/example-target-alias-type-mismatch.json create mode 100644 packages/tests/fixtures/registry/example-target-aliases.json diff --git a/.changeset/stupid-fans-type.md b/.changeset/stupid-fans-type.md new file mode 100644 index 0000000000..4f7b4c7077 --- /dev/null +++ b/.changeset/stupid-fans-type.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +allow alias placeholders in target for registry items diff --git a/apps/v4/content/docs/registry/examples.mdx b/apps/v4/content/docs/registry/examples.mdx index a537a4a505..a71ce65271 100644 --- a/apps/v4/content/docs/registry/examples.mdx +++ b/apps/v4/content/docs/registry/examples.mdx @@ -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 diff --git a/apps/v4/content/docs/registry/registry-item-json.mdx b/apps/v4/content/docs/registry/registry-item-json.mdx index e80c7c4824..f7badeee02 100644 --- a/apps/v4/content/docs/registry/registry-item-json.mdx +++ b/apps/v4/content/docs/registry/registry-item-json.mdx @@ -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. + + + `@utils/` is not supported because `utils` points to a file, not a directory. + + ### tailwind **DEPRECATED:** Use `cssVars.theme` instead for Tailwind v4 projects. diff --git a/apps/v4/public/schema/registry-item.json b/apps/v4/public/schema/registry-item.json index 110c7b9ce7..9255c6422e 100644 --- a/apps/v4/public/schema/registry-item.json +++ b/apps/v4/public/schema/registry-item.json @@ -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": { diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index d6510227b0..62252d2e1c 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -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() - - 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 = { + // 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() + const FILE_TYPE_TO_CONFIG_KEY: Record = { "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 + ) => { + 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, diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index bf1bf7a08b..3e9bb95fbd 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -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, config: Config diff --git a/packages/shadcn/test/utils/updaters/update-files.test.ts b/packages/shadcn/test/utils/updaters/update-files.test.ts index dd53690cc4..0b51fc548d 100644 --- a/packages/shadcn/test/utils/updaters/update-files.test.ts +++ b/packages/shadcn/test/utils/updaters/update-files.test.ts @@ -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", diff --git a/packages/tests/fixtures/registry/example-target-alias-child.json b/packages/tests/fixtures/registry/example-target-alias-child.json new file mode 100644 index 0000000000..7079d877db --- /dev/null +++ b/packages/tests/fixtures/registry/example-target-alias-child.json @@ -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 \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" + } + ] +} diff --git a/packages/tests/fixtures/registry/example-target-alias-parent.json b/packages/tests/fixtures/registry/example-target-alias-parent.json new file mode 100644 index 0000000000..494fa21ede --- /dev/null +++ b/packages/tests/fixtures/registry/example-target-alias-parent.json @@ -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
Dependency Panel
\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" + } + ] +} diff --git a/packages/tests/fixtures/registry/example-target-alias-type-mismatch.json b/packages/tests/fixtures/registry/example-target-alias-type-mismatch.json new file mode 100644 index 0000000000..017586d8ed --- /dev/null +++ b/packages/tests/fixtures/registry/example-target-alias-type-mismatch.json @@ -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" + } + ] +} diff --git a/packages/tests/fixtures/registry/example-target-aliases.json b/packages/tests/fixtures/registry/example-target-aliases.json new file mode 100644 index 0000000000..ff09a584bd --- /dev/null +++ b/packages/tests/fixtures/registry/example-target-aliases.json @@ -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 \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
Target Panel
\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" + } + ] +} diff --git a/packages/tests/src/tests/add.test.ts b/packages/tests/src/tests/add.test.ts index c811cda348..ee9671733d 100644 --- a/packages/tests/src/tests/add.test.ts +++ b/packages/tests/src/tests/add.test.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"