diff --git a/.changeset/few-flies-fry.md b/.changeset/few-flies-fry.md
new file mode 100644
index 0000000000..e863404e6c
--- /dev/null
+++ b/.changeset/few-flies-fry.md
@@ -0,0 +1,5 @@
+---
+"shadcn": minor
+---
+
+add support for package imports
diff --git a/apps/v4/content/docs/(root)/components-json.mdx b/apps/v4/content/docs/(root)/components-json.mdx
index 1d4bada357..4064f89223 100644
--- a/apps/v4/content/docs/(root)/components-json.mdx
+++ b/apps/v4/content/docs/(root)/components-json.mdx
@@ -140,15 +140,91 @@ Setting this option to `false` allows components to be added as JavaScript with
## aliases
-The CLI uses these values and the `paths` config from your `tsconfig.json` or `jsconfig.json` file to place generated components in the correct location.
+The CLI uses these values to place generated components in the correct location and rewrite imports.
-Path aliases have to be set up in your `tsconfig.json` or `jsconfig.json` file.
+You can back these aliases with either:
+
+1. `compilerOptions.paths` in your `tsconfig.json` or `jsconfig.json`
+2. `package.json#imports` with TypeScript package import resolution enabled
+
+The aliases in `components.json` are still required when using the CLI. They tell the CLI which import roots map to `components`, `ui`, `lib`, `hooks`, and `utils`.
- **Important:** If you're using the `src` directory, make sure it is included
- under `paths` in your `tsconfig.json` or `jsconfig.json` file.
+ **Important:** If you're using package imports, enable
+ `resolvePackageJsonImports` and use `moduleResolution: "bundler"` in your
+ `tsconfig.json`. If you're using `paths`, make sure your aliases include the
+ `src` directory when applicable.
+### Using `tsconfig` or `jsconfig` paths
+
+```json title="tsconfig.json"
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
+```
+
+### Using `package.json#imports`
+
+Recommended setup for a single-package app:
+
+```json title="package.json"
+{
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ }
+}
+```
+
+```json title="tsconfig.json"
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "resolvePackageJsonImports": true
+ }
+}
+```
+
+```json title="components.json"
+{
+ "aliases": {
+ "components": "#components",
+ "ui": "#components/ui",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#lib/utils"
+ }
+}
+```
+
+The aliases in `components.json` still tell the CLI where to place
+`components`, `ui`, `lib`, `hooks`, and `utils`. `package.json#imports`
+provides the runtime and TypeScript resolution for those `#...` specifiers.
+
+The matched `imports` target also controls whether generated `#...` imports keep
+file extensions:
+
+- `"#components/*": "./src/components/*"` preserves source extensions and can
+ generate imports like
+ `#components/button.tsx`
+- `"#components/*": "./src/components/*.tsx"` strips source extensions and
+ generates imports like
+ `#components/button`
+
+For monorepos, see the monorepo docs. Local
+workspace aliases can use `package.json#imports`, while shared workspace
+imports such as `@workspace/ui/components` are resolved from the target
+package's `exports`.
+
+For framework-specific setup, see the [package imports guide](/docs/package-imports).
+
### aliases.utils
Import alias for your utility functions.
diff --git a/apps/v4/content/docs/(root)/meta.json b/apps/v4/content/docs/(root)/meta.json
index 8162ed9d9a..a1ad489597 100644
--- a/apps/v4/content/docs/(root)/meta.json
+++ b/apps/v4/content/docs/(root)/meta.json
@@ -4,6 +4,7 @@
"index",
"[Installation](/docs/installation)",
"components-json",
+ "package-imports",
"theming",
"[Dark Mode](/docs/dark-mode)",
"[RTL](/docs/rtl)",
diff --git a/apps/v4/content/docs/(root)/monorepo.mdx b/apps/v4/content/docs/(root)/monorepo.mdx
index 114f9abc8e..bf2c2f88d7 100644
--- a/apps/v4/content/docs/(root)/monorepo.mdx
+++ b/apps/v4/content/docs/(root)/monorepo.mdx
@@ -164,3 +164,91 @@ turbo.json
4. **For Tailwind CSS v4, leave the `tailwind` config empty in the `components.json` file.**
By following these requirements, the CLI will be able to install ui components, blocks, libs and hooks to the correct paths and handle imports for you.
+
+
+ `package.json#imports` works well for package-local aliases inside a
+ workspace, for example inside `packages/ui`. For shared workspace imports such
+ as `@workspace/ui/components`, keep explicit aliases in `components.json`. The
+ CLI uses those aliases to route files across workspace boundaries.
+
+
+## Using `package.json#imports`
+
+For a monorepo that uses package imports and does not rely on
+`tsconfig.json` `paths`, use:
+
+- local `#...` aliases for files inside each workspace
+- workspace package `exports` for shared imports such as
+ `@workspace/ui/components`
+
+For example, an app workspace can use local package imports:
+
+```json showLineNumbers title="apps/web/package.json"
+{
+ "name": "web",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ },
+ "dependencies": {
+ "@workspace/ui": "workspace:*"
+ }
+}
+```
+
+```json showLineNumbers title="apps/web/components.json"
+{
+ "aliases": {
+ "components": "#components",
+ "ui": "@workspace/ui/components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "@workspace/ui/lib/utils"
+ }
+}
+```
+
+And the shared UI package can expose its install targets with `exports`:
+
+```json showLineNumbers title="packages/ui/package.json"
+{
+ "name": "@workspace/ui",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ },
+ "exports": {
+ "./globals.css": "./src/styles/globals.css",
+ "./components/*": "./src/components/*.tsx",
+ "./lib/*": "./src/lib/*.ts",
+ "./hooks/*": "./src/hooks/*.ts"
+ }
+}
+```
+
+```json showLineNumbers title="packages/ui/components.json"
+{
+ "aliases": {
+ "components": "#components",
+ "ui": "#components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#lib/utils"
+ }
+}
+```
+
+In this setup:
+
+- files added from the app to the shared UI package are routed through
+ `@workspace/ui/...`
+- files added inside `packages/ui` use the package-local `#...` aliases
+- the shared package must export any path referenced by another workspace
+
+For framework-specific package import setup, see the [package imports guide](/docs/package-imports).
diff --git a/apps/v4/content/docs/(root)/package-imports.mdx b/apps/v4/content/docs/(root)/package-imports.mdx
new file mode 100644
index 0000000000..79b83f20ba
--- /dev/null
+++ b/apps/v4/content/docs/(root)/package-imports.mdx
@@ -0,0 +1,234 @@
+---
+title: Package Imports
+description: Configure shadcn/ui with package.json imports.
+---
+
+The `shadcn` CLI supports [package imports](https://nodejs.org/api/packages.html#imports)
+for installing components, rewriting imports, and resolving third-party
+registries.
+
+Package imports let you use private `#...` import aliases from your
+`package.json` instead of `compilerOptions.paths` in `tsconfig.json`.
+
+## Example
+
+You configure `imports` in your `package.json`:
+
+```json title="package.json" showLineNumbers
+{
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ }
+}
+```
+
+Then import generated components using `#...` specifiers:
+
+```tsx
+import { Button } from "#components/ui/button"
+import { cn } from "#lib/utils"
+```
+
+
+ Package import specifiers must start with `#`. Use TypeScript 5 or later with
+ `moduleResolution: "bundler"` and `resolvePackageJsonImports: true`.
+
+
+## App
+
+For Next.js, Vite, and TanStack Start apps that install
+components into the same workspace.
+
+
+
+### Configure `package.json`
+
+Add imports for the shadcn/ui install targets.
+
+```json title="package.json" showLineNumbers
+{
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ }
+}
+```
+
+If your app does not use a `src` directory, remove `src/` from the targets. For
+example:
+
+```json title="package.json" showLineNumbers
+{
+ "imports": {
+ "#components/*": "./components/*.tsx",
+ "#lib/*": "./lib/*.ts",
+ "#hooks/*": "./hooks/*.ts"
+ }
+}
+```
+
+### Configure TypeScript
+
+Enable package import resolution.
+
+```json title="tsconfig.json" showLineNumbers
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "resolvePackageJsonImports": true
+ }
+}
+```
+
+You do not need `compilerOptions.paths` for these aliases.
+
+### Configure `components.json`
+
+Use the same `#...` roots in `components.json`.
+
+```json title="components.json" showLineNumbers
+{
+ "aliases": {
+ "components": "#components",
+ "ui": "#components/ui",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#lib/utils"
+ }
+}
+```
+
+The `ui` alias uses `#components/ui`. It is still covered by the
+`#components/*` import in `package.json`.
+
+The `utils` alias uses `#lib/utils`. It is covered by `#lib/*`, so you do not
+need a separate `#utils` import.
+
+
+
+## Monorepo
+
+In a monorepo, use package imports for files inside each package and package
+exports for files shared across workspaces.
+
+For an app workspace:
+
+```json title="apps/web/package.json" showLineNumbers
+{
+ "name": "web",
+ "private": true,
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ },
+ "dependencies": {
+ "@workspace/ui": "workspace:*"
+ }
+}
+```
+
+```json title="apps/web/components.json" showLineNumbers
+{
+ "aliases": {
+ "components": "#components",
+ "ui": "@workspace/ui/components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "@workspace/ui/lib/utils"
+ }
+}
+```
+
+For the shared UI package:
+
+```json title="packages/ui/package.json" showLineNumbers
+{
+ "name": "@workspace/ui",
+ "private": true,
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ },
+ "exports": {
+ "./globals.css": "./src/styles/globals.css",
+ "./components/*": "./src/components/*.tsx",
+ "./lib/*": "./src/lib/*.ts",
+ "./hooks/*": "./src/hooks/*.ts"
+ }
+}
+```
+
+```json title="packages/ui/components.json" showLineNumbers
+{
+ "aliases": {
+ "components": "#components",
+ "ui": "#components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#lib/utils"
+ }
+}
+```
+
+When you run `add` from `apps/web`, app-local files use `#...` imports and
+shared UI files are imported from `@workspace/ui`.
+
+```tsx
+import { Button } from "@workspace/ui/components/button"
+import { LoginForm } from "#components/login-form"
+```
+
+## File extensions
+
+The target pattern in `package.json#imports` controls whether generated imports
+include file extensions.
+
+```json title="package.json" showLineNumbers
+{
+ "imports": {
+ "#components/*": "./src/components/*.tsx"
+ }
+}
+```
+
+This generates imports without extensions:
+
+```tsx
+import { Button } from "#components/ui/button"
+```
+
+If you use a target without the extension:
+
+```json title="package.json" showLineNumbers
+{
+ "imports": {
+ "#components/*": "./src/components/*"
+ }
+}
+```
+
+The generated import keeps the source extension:
+
+```tsx
+import { Button } from "#components/ui/button.tsx"
+```
+
+For most apps, use the extension in the target pattern.
+
+## Troubleshooting
+
+If TypeScript cannot resolve a `#...` import, check that:
+
+- the specifier starts with `#`
+- the `imports` entry is in the nearest `package.json`
+- `moduleResolution` is set to `bundler`
+- `resolvePackageJsonImports` is enabled
+- the matching target exists after components are added
+
+If a component is installed but imports still point to `@/...`, check that
+`components.json` uses the same `#...` aliases as your package imports.
diff --git a/apps/v4/content/docs/installation/manual.mdx b/apps/v4/content/docs/installation/manual.mdx
index 3d338fe1c8..c496305676 100644
--- a/apps/v4/content/docs/installation/manual.mdx
+++ b/apps/v4/content/docs/installation/manual.mdx
@@ -19,9 +19,11 @@ Add the following dependencies to your project:
npm install shadcn class-variance-authority clsx tailwind-merge lucide-react tw-animate-css
```
-### Configure path aliases
+### Configure import aliases
-Configure the path aliases in your `tsconfig.json` file.
+Choose one of the following alias setups.
+
+#### Option A: `tsconfig.json` paths
```json {3-6} title="tsconfig.json" showLineNumbers
{
@@ -34,7 +36,31 @@ Configure the path aliases in your `tsconfig.json` file.
}
```
-The `@` alias is a preference. You can use other aliases if you want.
+#### Option B: `package.json#imports`
+
+```json title="package.json" showLineNumbers
+{
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts"
+ }
+}
+```
+
+```json title="tsconfig.json" showLineNumbers
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "resolvePackageJsonImports": true
+ }
+}
+```
+
+The `@` alias is a preference. You can use other aliases if you want. If you
+use `package.json#imports`, keep the matching alias roots in `components.json`.
+See the package imports guide for
+framework-specific setup.
### Configure styles
@@ -211,6 +237,20 @@ Create a `components.json` file in the root of your project.
}
```
+If you're using `package.json#imports`, use the corresponding `#...` aliases instead:
+
+```json title="components.json" showLineNumbers
+{
+ "aliases": {
+ "components": "#components",
+ "utils": "#lib/utils",
+ "ui": "#components/ui",
+ "lib": "#lib",
+ "hooks": "#hooks"
+ }
+}
+```
+
### That's it
You can now start adding components to your project.
diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts
index e0b6c1c3ab..456812c0d1 100644
--- a/packages/shadcn/src/commands/init.ts
+++ b/packages/shadcn/src/commands/init.ts
@@ -21,6 +21,7 @@ import {
templates,
} from "@/src/templates/index"
import { addComponents } from "@/src/utils/add-components"
+import { getInitAliasDefaults } from "@/src/utils/alias"
import { createProject } from "@/src/utils/create-project"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
@@ -588,6 +589,7 @@ export async function runInit(
}
) {
let projectInfo
+ let projectConfig
let newProjectTemplate: keyof typeof templates | undefined
// Resolve the effective template if --monorepo is set.
@@ -629,7 +631,7 @@ export async function runInit(
projectInfo = await getProjectInfo(options.cwd)
}
- const didCreateProject = Boolean(newProjectTemplate)
+ projectConfig = await getProjectConfig(options.cwd, projectInfo)
// Use the template from project creation if available,
// or fall back to the explicit --template flag.
@@ -644,6 +646,12 @@ export async function runInit(
// Add button component for new template-based projects.
...(selectedTemplate ? ["button"] : []),
]
+ // Tie postInit to actual project creation in this run (createProject
+ // sets newProjectTemplate). A caller-provided `options.isNewProject`
+ // alone should not trigger postInit.
+ const templatePostInit = newProjectTemplate
+ ? selectedTemplate?.postInit
+ : undefined
if (selectedTemplate?.init) {
const result = await selectedTemplate.init({
@@ -657,17 +665,15 @@ export async function runInit(
silent: options.silent,
})
- // Run postInit only for newly scaffolded projects (e.g. git init).
- if (didCreateProject) {
- await selectedTemplate.postInit({ projectPath: options.cwd })
+ if (templatePostInit) {
+ // Run postInit for newly scaffolded projects (e.g. git init).
+ await templatePostInit({ projectPath: options.cwd })
}
return result
}
// Standard init path for existing projects.
- const projectConfig = await getProjectConfig(options.cwd, projectInfo)
-
let config = projectConfig
? await promptForMinimalConfig(projectConfig, options)
: await promptForConfig(await getConfig(options.cwd))
@@ -797,9 +803,9 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
- // Run postInit for newly scaffolded projects without a custom init (e.g. git init).
- if (selectedTemplate && didCreateProject) {
- await selectedTemplate.postInit({ projectPath: options.cwd })
+ // Run postInit only for newly scaffolded projects.
+ if (templatePostInit) {
+ await templatePostInit({ projectPath: options.cwd })
}
return fullConfig
@@ -883,12 +889,6 @@ async function promptForConfig(defaultConfig: Config | null = null) {
)}:`,
initial: defaultConfig?.aliases["components"] ?? DEFAULT_COMPONENTS,
},
- {
- type: "text",
- name: "utils",
- message: `Configure the import alias for ${highlighter.info("utils")}:`,
- initial: defaultConfig?.aliases["utils"] ?? DEFAULT_UTILS,
- },
{
type: "toggle",
name: "rsc",
@@ -903,6 +903,16 @@ async function promptForConfig(defaultConfig: Config | null = null) {
process.exit(1)
}
+ const existingAliases =
+ defaultConfig && defaultConfig.aliases.components === options.components
+ ? defaultConfig.aliases
+ : undefined
+
+ const aliasDefaults = getInitAliasDefaults(
+ options.components,
+ existingAliases
+ )
+
return rawConfigSchema.parse({
$schema: "https://ui.shadcn.com/schema.json",
style: options.style,
@@ -916,11 +926,11 @@ async function promptForConfig(defaultConfig: Config | null = null) {
rsc: options.rsc,
tsx: options.typescript,
aliases: {
- utils: options.utils,
components: options.components,
- // TODO: fix this.
- lib: options.components.replace(/\/components$/, "lib"),
- hooks: options.components.replace(/\/components$/, "hooks"),
+ ui: aliasDefaults.ui,
+ lib: aliasDefaults.lib,
+ hooks: aliasDefaults.hooks,
+ utils: aliasDefaults.utils,
},
})
}
diff --git a/packages/shadcn/src/preflights/preflight-init.test.ts b/packages/shadcn/src/preflights/preflight-init.test.ts
new file mode 100644
index 0000000000..b8e8da471e
--- /dev/null
+++ b/packages/shadcn/src/preflights/preflight-init.test.ts
@@ -0,0 +1,140 @@
+import { preFlightInit } from "@/src/preflights/preflight-init"
+import { afterEach, describe, expect, test, vi } from "vitest"
+import { z } from "zod"
+
+const { mockedGetProjectInfo, mockedExistsSync, mockedLogger } = vi.hoisted(
+ () => ({
+ mockedGetProjectInfo: vi.fn(),
+ mockedExistsSync: vi.fn(),
+ mockedLogger: {
+ break: vi.fn(),
+ error: vi.fn(),
+ },
+ })
+)
+
+vi.mock("@/src/commands/init", () => ({
+ initOptionsSchema: z.object({
+ cwd: z.string(),
+ force: z.boolean(),
+ monorepo: z.boolean().optional(),
+ silent: z.boolean().optional(),
+ existingConfig: z.record(z.unknown()).optional(),
+ }),
+}))
+
+vi.mock("@/src/utils/get-project-info", () => ({
+ getProjectInfo: mockedGetProjectInfo,
+}))
+
+vi.mock("@/src/utils/get-monorepo-info", () => ({
+ formatMonorepoMessage: vi.fn(),
+ getMonorepoTargets: vi.fn().mockResolvedValue([]),
+ isMonorepoRoot: vi.fn().mockResolvedValue(false),
+}))
+
+vi.mock("@/src/utils/highlighter", () => ({
+ highlighter: {
+ info: (value: string) => value,
+ },
+}))
+
+vi.mock("@/src/utils/logger", () => ({
+ logger: mockedLogger,
+}))
+
+vi.mock("@/src/utils/spinner", () => ({
+ spinner: vi.fn().mockReturnValue({
+ start: vi.fn().mockReturnValue({
+ succeed: vi.fn(),
+ fail: vi.fn(),
+ stop: vi.fn(),
+ }),
+ }),
+}))
+
+vi.mock("fs-extra", () => ({
+ default: {
+ existsSync: mockedExistsSync,
+ },
+}))
+
+const baseProjectInfo = {
+ framework: {
+ name: "next-app",
+ label: "Next.js",
+ links: {
+ installation: "https://ui.shadcn.com/docs/installation",
+ tailwind: "https://tailwindcss.com/docs/installation",
+ },
+ },
+ isSrcDir: false,
+ isRSC: true,
+ isTsx: true,
+ tailwindConfigFile: null,
+ tailwindCssFile: "app/globals.css",
+ tailwindVersion: "v4" as const,
+ frameworkVersion: null,
+ aliasPrefix: "#",
+}
+
+const baseOptions = {
+ cwd: "/tmp/project",
+ cssVariables: true,
+ defaults: false,
+ force: false,
+ installStyleIndex: true,
+ isNewProject: false,
+ monorepo: false,
+ silent: true,
+ yes: true,
+}
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe("preFlightInit", () => {
+ test("accepts package import aliases detected from package.json#imports", async () => {
+ mockedExistsSync.mockImplementation((filePath: string) => {
+ return !filePath.endsWith("components.json")
+ })
+ mockedGetProjectInfo.mockResolvedValue(baseProjectInfo)
+
+ const result = await preFlightInit(baseOptions)
+
+ expect(result.errors).toEqual({})
+ expect(result.projectInfo?.aliasPrefix).toBe("#")
+ expect(mockedLogger.error).not.toHaveBeenCalled()
+ })
+
+ test("reports missing aliases for tsconfig paths and package imports", async () => {
+ mockedExistsSync.mockImplementation((filePath: string) => {
+ return !filePath.endsWith("components.json")
+ })
+ mockedGetProjectInfo.mockResolvedValue({
+ ...baseProjectInfo,
+ aliasPrefix: null,
+ })
+
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
+ code?: string | number | null
+ ) => {
+ throw new Error(`process.exit:${code ?? ""}`)
+ }) as never)
+
+ await expect(preFlightInit(baseOptions)).rejects.toThrow("process.exit:1")
+
+ expect(mockedLogger.error).toHaveBeenCalledWith(
+ "Could not find valid path aliases or package imports for init."
+ )
+ expect(mockedLogger.error).toHaveBeenCalledWith(
+ "Configure path aliases in tsconfig.json or imports in package.json, then run init again."
+ )
+ expect(mockedLogger.error).toHaveBeenCalledWith(
+ "Learn more at https://ui.shadcn.com/docs/installation/manual#configure-import-aliases."
+ )
+
+ exitSpy.mockRestore()
+ })
+})
diff --git a/packages/shadcn/src/preflights/preflight-init.ts b/packages/shadcn/src/preflights/preflight-init.ts
index be5340fe37..df430dbd7e 100644
--- a/packages/shadcn/src/preflights/preflight-init.ts
+++ b/packages/shadcn/src/preflights/preflight-init.ts
@@ -1,5 +1,6 @@
import path from "path"
import { initOptionsSchema } from "@/src/commands/init"
+import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import {
formatMonorepoMessage,
@@ -132,6 +133,7 @@ export async function preFlightInit(
const tsConfigSpinner = spinner(`Validating import alias.`, {
silent: options.silent,
}).start()
+
if (!projectInfo?.aliasPrefix) {
errors[ERRORS.IMPORT_ALIAS_MISSING] = true
tsConfigSpinner?.fail()
@@ -162,14 +164,23 @@ export async function preFlightInit(
if (errors[ERRORS.IMPORT_ALIAS_MISSING]) {
logger.break()
- logger.error(`No import alias found in your tsconfig.json file.`)
- if (projectInfo?.framework.links.installation) {
- logger.error(
- `Visit ${highlighter.info(
- projectInfo?.framework.links.installation
- )} to learn how to set an import alias.`
- )
- }
+ logger.error(
+ `Could not find valid path aliases or package imports for ${highlighter.info(
+ "init"
+ )}.`
+ )
+ logger.error(
+ `Configure path aliases in ${highlighter.info(
+ "tsconfig.json"
+ )} or imports in ${highlighter.info("package.json")}, then run ${highlighter.info(
+ "init"
+ )} again.`
+ )
+ logger.error(
+ `Learn more at ${highlighter.info(
+ `${SHADCN_URL}/docs/installation/manual#configure-import-aliases`
+ )}.`
+ )
}
logger.break()
diff --git a/packages/shadcn/src/registry/utils.ts b/packages/shadcn/src/registry/utils.ts
index 7f1951f077..8e552a1114 100644
--- a/packages/shadcn/src/registry/utils.ts
+++ b/packages/shadcn/src/registry/utils.ts
@@ -8,7 +8,10 @@ import {
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { getProjectInfo, ProjectInfo } from "@/src/utils/get-project-info"
-import { resolveImport } from "@/src/utils/resolve-import"
+import {
+ isLocalAliasImport,
+ resolveImportWithMetadata,
+} from "@/src/utils/resolve-import"
import {
findCommonRoot,
resolveFilePath,
@@ -119,8 +122,9 @@ export async function recursivelyResolveFileImports(
const moduleSpecifier = importStatement.getModuleSpecifierValue()
const isRelativeImport = moduleSpecifier.startsWith(".")
- const isAliasImport = moduleSpecifier.startsWith(
- `${projectInfo.aliasPrefix}/`
+ const isAliasImport = isLocalAliasImport(
+ moduleSpecifier,
+ projectInfo.aliasPrefix
)
// If not a local import, add to the dependencies array.
@@ -132,7 +136,12 @@ export async function recursivelyResolveFileImports(
continue
}
- let probableImportFilePath = await resolveImport(moduleSpecifier, tsConfig)
+ let probableImportFilePath = (
+ await resolveImportWithMetadata(moduleSpecifier, {
+ ...tsConfig,
+ cwd: config.resolvedPaths.cwd,
+ })
+ )?.path
if (isRelativeImport) {
probableImportFilePath = path.resolve(
diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts
index 858b76d779..d6510227b0 100644
--- a/packages/shadcn/src/utils/add-components.ts
+++ b/packages/shadcn/src/utils/add-components.ts
@@ -270,16 +270,26 @@ async function addWorkspaceComponents(
"registry:hook": "hooks",
"registry:lib": "lib",
}
+ const getTargetConfigForType = (type: string) => {
+ const configKey = FILE_TYPE_TO_CONFIG_KEY[type]
+ 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)
+ const plannedFiles = (tree.files ?? []).filter((file) => {
+ const fileTargetConfig = getTargetConfigForType(
+ file.type || "registry:ui"
+ )
- const configKey = FILE_TYPE_TO_CONFIG_KEY[type]
- const targetConfig =
- configKey && workspaceConfig[configKey]
- ? workspaceConfig[configKey]
- : config
+ return (
+ fileTargetConfig.resolvedPaths.cwd === targetConfig.resolvedPaths.cwd
+ )
+ })
const typeWorkspaceRoot = findCommonRoot(
config.resolvedPaths.cwd,
@@ -299,6 +309,7 @@ async function addWorkspaceComponents(
isRemote: options.isRemote,
isWorkspace: true,
path: options.path,
+ plannedFiles,
supportedFontMarkers,
})
diff --git a/packages/shadcn/src/utils/alias.test.ts b/packages/shadcn/src/utils/alias.test.ts
new file mode 100644
index 0000000000..72133716c1
--- /dev/null
+++ b/packages/shadcn/src/utils/alias.test.ts
@@ -0,0 +1,77 @@
+import {
+ deriveAliasFromComponents,
+ getInitAliasDefaults,
+} from "@/src/utils/alias"
+import { describe, expect, test } from "vitest"
+
+describe("deriveAliasFromComponents", () => {
+ test("derives ui aliases from components", () => {
+ expect(deriveAliasFromComponents("@/components", "ui")).toBe(
+ "@/components/ui"
+ )
+ })
+
+ test("derives utils aliases from lib aliases", () => {
+ expect(deriveAliasFromComponents("#components", "utils")).toBe("#lib/utils")
+ expect(
+ deriveAliasFromComponents("#custom/components", "utils", "#custom/lib")
+ ).toBe("#custom/lib/utils")
+ })
+
+ test("derives sibling lib and hooks aliases from components", () => {
+ expect(deriveAliasFromComponents("@/components", "lib")).toBe("@/lib")
+ expect(deriveAliasFromComponents("#custom/components", "hooks")).toBe(
+ "#custom/hooks"
+ )
+ })
+
+ test("returns an empty string when components alias has no sibling base", () => {
+ expect(deriveAliasFromComponents("#custom/ui", "lib")).toBe("")
+ })
+})
+
+describe("getInitAliasDefaults", () => {
+ test("derives standard aliases from components", () => {
+ expect(getInitAliasDefaults("@/components")).toEqual({
+ ui: "@/components/ui",
+ lib: "@/lib",
+ hooks: "@/hooks",
+ utils: "@/lib/utils",
+ })
+ })
+
+ test("derives package import aliases from #components", () => {
+ expect(getInitAliasDefaults("#components")).toEqual({
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#lib/utils",
+ })
+ })
+
+ test("derives sibling aliases for nested custom aliases", () => {
+ expect(getInitAliasDefaults("#custom/components")).toEqual({
+ ui: "#custom/components/ui",
+ lib: "#custom/lib",
+ hooks: "#custom/hooks",
+ utils: "#custom/lib/utils",
+ })
+ })
+
+ test("preserves existing aliases when components alias is unchanged", () => {
+ expect(
+ getInitAliasDefaults("#components", {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#lib/utils",
+ })
+ ).toEqual({
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#lib/utils",
+ })
+ })
+})
diff --git a/packages/shadcn/src/utils/alias.ts b/packages/shadcn/src/utils/alias.ts
new file mode 100644
index 0000000000..eb4b5c65a4
--- /dev/null
+++ b/packages/shadcn/src/utils/alias.ts
@@ -0,0 +1,62 @@
+import type { Config } from "@/src/utils/get-config"
+import { DEFAULT_COMPONENTS, DEFAULT_UTILS } from "@/src/utils/get-config"
+
+export function getInitAliasDefaults(
+ componentsAlias: string,
+ existingAliases?: Config["aliases"]
+) {
+ // `lib` is the anchor for deriving `utils`, so reuse the existing value first
+ // when init is re-running against the same components alias.
+ const derivedLib =
+ existingAliases?.lib ?? deriveAliasFromComponents(componentsAlias, "lib")
+
+ return {
+ ui: existingAliases?.ui ?? deriveAliasFromComponents(componentsAlias, "ui"),
+ lib: derivedLib,
+ hooks:
+ existingAliases?.hooks ??
+ deriveAliasFromComponents(componentsAlias, "hooks"),
+ utils:
+ existingAliases?.utils ??
+ deriveAliasFromComponents(componentsAlias, "utils", derivedLib),
+ }
+}
+
+export function deriveAliasFromComponents(
+ componentsAlias: string,
+ kind: "ui" | "lib" | "hooks" | "utils",
+ libAlias?: string
+) {
+ const alias = componentsAlias || DEFAULT_COMPONENTS
+
+ if (kind === "ui") {
+ return `${alias}/ui`
+ }
+
+ if (kind === "utils") {
+ // `utils` follows `lib`, not `components`, so derive or reuse the sibling
+ // lib alias before appending `/utils`.
+ const resolvedLib = libAlias || replaceComponentsAliasTail(alias, "lib")
+ return resolvedLib ? `${resolvedLib}/utils` : DEFAULT_UTILS
+ }
+
+ return replaceComponentsAliasTail(alias, kind)
+}
+
+function replaceComponentsAliasTail(alias: string, kind: "lib" | "hooks") {
+ // Handles the common `@/components` and `#custom/components` forms by
+ // swapping the trailing `components` segment for a sibling alias root.
+ if (alias === "components") {
+ return kind
+ }
+
+ if (alias.endsWith("/components")) {
+ return `${alias.slice(0, -"/components".length)}/${kind}`
+ }
+
+ if (alias.endsWith("components") && !alias.includes("/")) {
+ return `${alias.slice(0, -"components".length)}${kind}`
+ }
+
+ return ""
+}
diff --git a/packages/shadcn/src/utils/dry-run.ts b/packages/shadcn/src/utils/dry-run.ts
index b214207aad..6c196426a2 100644
--- a/packages/shadcn/src/utils/dry-run.ts
+++ b/packages/shadcn/src/utils/dry-run.ts
@@ -24,9 +24,13 @@ import { transformCss } from "@/src/utils/updaters/update-css"
import { transformCssVars } from "@/src/utils/updaters/update-css-vars"
import {
findCommonRoot,
+ getPlannedFilePaths,
resolveFilePath,
+ rewriteResolvedImportsInContent,
} from "@/src/utils/updaters/update-files"
import { massageTreeForFonts } from "@/src/utils/updaters/update-fonts"
+import { Project } from "ts-morph"
+import { loadConfig } from "tsconfig-paths"
import type { z } from "zod"
export type DryRunFile = {
@@ -144,6 +148,19 @@ async function processFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
+ let tsConfig: ReturnType
+ try {
+ tsConfig = loadConfig(config.resolvedPaths.cwd)
+ } catch {
+ tsConfig = { resultType: "failed" } as ReturnType
+ }
+ const project = new Project({
+ compilerOptions: {},
+ })
+ const plannedFilePaths = getPlannedFilePaths(files, config, {
+ isSrcDir: projectInfo?.isSrcDir,
+ framework: projectInfo?.framework.name,
+ })
for (let index = 0; index < files.length; index++) {
const file = files[index]
@@ -203,13 +220,25 @@ async function processFiles(
transformCleanup,
]
)
+ const finalContent =
+ isEnvFile(filePath) || isUniversalItemFile
+ ? content
+ : await rewriteResolvedImportsInContent({
+ config,
+ content,
+ filePaths: plannedFilePaths,
+ project,
+ projectInfo,
+ resolvedPath: filePath,
+ tsConfig,
+ })
// Determine action.
let action: DryRunFile["action"] = "create"
let oldContent: string | undefined
if (existingFile) {
oldContent = await fs.readFile(filePath, "utf-8")
- if (isContentSame(oldContent, content)) {
+ if (isContentSame(oldContent, finalContent)) {
action = "skip"
} else {
action = "overwrite"
@@ -219,7 +248,7 @@ async function processFiles(
result.files.push({
path: relativePath,
action,
- content,
+ content: finalContent,
...(action === "overwrite" && { existingContent: oldContent }),
type: file.type ?? "registry:ui",
})
diff --git a/packages/shadcn/src/utils/get-config.ts b/packages/shadcn/src/utils/get-config.ts
index 9e7784ab70..85141eaf6c 100644
--- a/packages/shadcn/src/utils/get-config.ts
+++ b/packages/shadcn/src/utils/get-config.ts
@@ -7,10 +7,10 @@ import {
} from "@/src/schema"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
-import { resolveImport } from "@/src/utils/resolve-import"
+import { resolveImportWithMetadata } from "@/src/utils/resolve-import"
import { cosmiconfig } from "cosmiconfig"
import fg from "fast-glob"
-import { loadConfig } from "tsconfig-paths"
+import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { z } from "zod"
export const DEFAULT_STYLE = "default"
@@ -64,6 +64,37 @@ export async function resolveConfigPaths(
)
}
+ // Resolve the primary aliases first so fallbacks can reuse their results.
+ const resolvedUtils = await resolveAliasPath(
+ "utils",
+ config.aliases["utils"],
+ cwd,
+ tsConfig
+ )
+ const resolvedComponents = await resolveAliasPath(
+ "components",
+ config.aliases["components"],
+ cwd,
+ tsConfig
+ )
+ const resolvedUi = config.aliases["ui"]
+ ? await resolveAliasPath("ui", config.aliases["ui"], cwd, tsConfig)
+ : path.resolve(resolvedComponents ?? cwd, "ui")
+ const resolvedLib = config.aliases["lib"]
+ ? await resolveAliasPath("lib", config.aliases["lib"], cwd, tsConfig)
+ : path.resolve(resolvedUtils ?? cwd, "..")
+ const resolvedHooks = config.aliases["hooks"]
+ ? await resolveAliasPath("hooks", config.aliases["hooks"], cwd, tsConfig)
+ : path.resolve(resolvedComponents ?? cwd, "..", "hooks")
+
+ assertResolvedAliases(cwd, {
+ components: resolvedComponents,
+ utils: resolvedUtils,
+ ui: resolvedUi,
+ lib: resolvedLib,
+ hooks: resolvedHooks,
+ })
+
return configSchema.parse({
...config,
resolvedPaths: {
@@ -72,35 +103,93 @@ export async function resolveConfigPaths(
? path.resolve(cwd, config.tailwind.config)
: "",
tailwindCss: path.resolve(cwd, config.tailwind.css),
- utils: await resolveImport(config.aliases["utils"], tsConfig),
- components: await resolveImport(config.aliases["components"], tsConfig),
- ui: config.aliases["ui"]
- ? await resolveImport(config.aliases["ui"], tsConfig)
- : path.resolve(
- (await resolveImport(config.aliases["components"], tsConfig)) ??
- cwd,
- "ui"
- ),
+ utils: resolvedUtils,
+ components: resolvedComponents,
+ ui: resolvedUi,
// TODO: Make this configurable.
// For now, we assume the lib and hooks directories are one level up from the components directory.
- lib: config.aliases["lib"]
- ? await resolveImport(config.aliases["lib"], tsConfig)
- : path.resolve(
- (await resolveImport(config.aliases["utils"], tsConfig)) ?? cwd,
- ".."
- ),
- hooks: config.aliases["hooks"]
- ? await resolveImport(config.aliases["hooks"], tsConfig)
- : path.resolve(
- (await resolveImport(config.aliases["components"], tsConfig)) ??
- cwd,
- "..",
- "hooks"
- ),
+ lib: resolvedLib,
+ hooks: resolvedHooks,
},
})
}
+async function resolveAliasPath(
+ aliasKey: "components" | "utils" | "ui" | "lib" | "hooks",
+ alias: string,
+ cwd: string,
+ tsConfig: Pick
+) {
+ const resolved = await resolveImportWithMetadata(alias, {
+ ...tsConfig,
+ cwd,
+ })
+
+ if (!resolved?.path) {
+ return null
+ }
+
+ if (alias.startsWith("#") && resolved.path === path.resolve(cwd, alias)) {
+ return null
+ }
+
+ // For non-utils alias keys backed by package imports or workspace exports,
+ // strip directory-level artifacts so the resolved path points at the
+ // directory root rather than a specific file.
+ if (
+ aliasKey !== "utils" &&
+ (resolved.source === "package_imports" ||
+ resolved.source === "workspace_package_exports")
+ ) {
+ // Exact aliases (e.g. `#hooks` → `./src/hooks/index.ts`) should resolve
+ // to the directory root.
+ if (
+ !resolved.matchedAlias.includes("*") &&
+ /\/index\.[^/]+$/.test(resolved.path)
+ ) {
+ return path.dirname(resolved.path)
+ }
+
+ // Wildcard aliases with explicit extensions (e.g. `#components/*` →
+ // `./src/components/*.tsx`) should strip the source extension so `ui`
+ // resolves to `/src/components/ui` instead of `/src/components/ui.tsx`.
+ if (resolved.matchedAlias.includes("*") && /\.[^/]+$/.test(resolved.path)) {
+ return resolved.path.replace(/\.[^/]+$/, "")
+ }
+ }
+
+ return resolved.path
+}
+
+function assertResolvedAliases(
+ cwd: string,
+ resolvedAliases: Record<
+ "components" | "utils" | "ui" | "lib" | "hooks",
+ string | null
+ >
+) {
+ const missingAliases = ["components", "ui", "lib", "hooks", "utils"].filter(
+ (key) => !resolvedAliases[key as keyof typeof resolvedAliases]
+ )
+
+ if (!missingAliases.length) {
+ return
+ }
+
+ throw new Error(
+ [
+ `Could not resolve the following aliases in ${highlighter.info(cwd)}: ${highlighter.info(
+ missingAliases.join(", ")
+ )}.`,
+ `Configure path aliases in ${highlighter.info(
+ "tsconfig.json"
+ )} or imports in ${highlighter.info(
+ "package.json"
+ )} for this workspace and try again.`,
+ ].join("\n")
+ )
+}
+
export async function getRawConfig(
cwd: string
): Promise | null> {
@@ -158,7 +247,20 @@ export async function getWorkspaceConfig(config: Config) {
continue
}
- resolvedAliases[key] = await getConfig(packageRoot)
+ const workspaceConfig = await getConfig(packageRoot)
+
+ if (!workspaceConfig) {
+ throw new Error(
+ [
+ `Could not load the workspace config in ${highlighter.info(packageRoot)}.`,
+ `Add ${highlighter.info(
+ "components.json"
+ )} to this workspace and configure its path aliases or package imports, then try again.`,
+ ].join("\n")
+ )
+ }
+
+ resolvedAliases[key] = workspaceConfig
}
const result = workspaceConfigSchema.safeParse(resolvedAliases)
diff --git a/packages/shadcn/src/utils/get-monorepo-info.ts b/packages/shadcn/src/utils/get-monorepo-info.ts
index 08fea27577..961b4ab18a 100644
--- a/packages/shadcn/src/utils/get-monorepo-info.ts
+++ b/packages/shadcn/src/utils/get-monorepo-info.ts
@@ -127,7 +127,7 @@ export function formatMonorepoMessage(
logger.break()
}
-async function getWorkspacePatterns(cwd: string) {
+export async function getWorkspacePatterns(cwd: string) {
const patterns: string[] = []
// Read pnpm-workspace.yaml.
diff --git a/packages/shadcn/src/utils/get-project-info.ts b/packages/shadcn/src/utils/get-project-info.ts
index affc185ef3..209e1c24bc 100644
--- a/packages/shadcn/src/utils/get-project-info.ts
+++ b/packages/shadcn/src/utils/get-project-info.ts
@@ -6,6 +6,10 @@ import { rawConfigSchema } from "@/src/schema"
import { Framework, FRAMEWORKS } from "@/src/utils/frameworks"
import { Config, getConfig, resolveConfigPaths } from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
+import {
+ getPackageImportAliases,
+ getPackageImportPrefix,
+} from "@/src/utils/package-imports"
import fg from "fast-glob"
import fs from "fs-extra"
import { loadConfig } from "tsconfig-paths"
@@ -50,7 +54,7 @@ export async function getProjectInfo(
tailwindConfigFile,
tailwindCssFile,
tailwindVersion,
- aliasPrefix,
+ aliasPrefixInfo,
packageJson,
] = await Promise.all([
fg.glob(
@@ -66,7 +70,7 @@ export async function getProjectInfo(
getTailwindConfigFile(cwd),
getTailwindCssFile(cwd, opts?.configCssFile),
getTailwindVersion(cwd),
- getTsConfigAliasPrefix(cwd),
+ getProjectAliasInfo(cwd),
getPackageInfo(cwd, false),
])
@@ -83,7 +87,7 @@ export async function getProjectInfo(
tailwindCssFile,
tailwindVersion,
frameworkVersion: null,
- aliasPrefix,
+ aliasPrefix: aliasPrefixInfo.prefix,
}
// Next.js.
@@ -300,28 +304,62 @@ export async function getTailwindConfigFile(cwd: string) {
export async function getTsConfigAliasPrefix(cwd: string) {
const tsConfig = await loadConfig(cwd)
+ const paths =
+ tsConfig?.resultType === "success" && Object.entries(tsConfig.paths).length
+ ? tsConfig.paths
+ : (await getTsConfig(cwd))?.compilerOptions.paths
- if (
- tsConfig?.resultType === "failed" ||
- !Object.entries(tsConfig?.paths).length
- ) {
+ if (!paths || !Object.entries(paths).length) {
return null
}
// This assume that the first alias is the prefix.
- for (const [alias, paths] of Object.entries(tsConfig.paths)) {
+ for (const [alias, targets] of Object.entries(paths)) {
+ const values = Array.isArray(targets) ? targets : [targets]
+
if (
- paths.includes("./*") ||
- paths.includes("./src/*") ||
- paths.includes("./app/*") ||
- paths.includes("./resources/js/*") // Laravel.
+ values.includes("./*") ||
+ values.includes("./src/*") ||
+ values.includes("./app/*") ||
+ values.includes("./resources/js/*") // Laravel.
) {
return alias.replace(/\/\*$/, "") ?? null
}
}
// Use the first alias as the prefix.
- return Object.keys(tsConfig?.paths)?.[0].replace(/\/\*$/, "") ?? null
+ return Object.keys(paths)?.[0].replace(/\/\*$/, "") ?? null
+}
+
+export async function getProjectAliasInfo(cwd: string) {
+ const tsConfigAliasPrefix = await getTsConfigAliasPrefix(cwd)
+ const packageImportPrefix = getPackageImportPrefix(cwd)
+
+ if (packageImportPrefix && tsConfigAliasPrefix?.startsWith("#")) {
+ return {
+ prefix: packageImportPrefix,
+ source: "package_imports" as const,
+ }
+ }
+
+ if (tsConfigAliasPrefix) {
+ return {
+ prefix: tsConfigAliasPrefix,
+ source: "tsconfig_paths" as const,
+ }
+ }
+
+ if (packageImportPrefix) {
+ return {
+ prefix: packageImportPrefix,
+ source: "package_imports" as const,
+ }
+ }
+
+ return {
+ prefix: null,
+ source: null,
+ }
}
export async function isTypeScriptProject(cwd: string) {
@@ -345,10 +383,16 @@ export async function getTsConfig(cwd: string) {
continue
}
- // We can't use fs.readJSON because it doesn't support comments.
const contents = await fs.readFile(filePath, "utf8")
- const cleanedContents = contents.replace(/\/\*\s*\*\//g, "")
- const result = TS_CONFIG_SCHEMA.safeParse(JSON.parse(cleanedContents))
+ let parsed
+
+ try {
+ parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(contents))
+ } catch {
+ continue
+ }
+
+ const result = TS_CONFIG_SCHEMA.safeParse(parsed)
if (result.error) {
continue
@@ -360,16 +404,133 @@ export async function getTsConfig(cwd: string) {
return null
}
+function stripJsonCommentsAndTrailingCommas(value: string) {
+ let result = ""
+ let inString = false
+ let escaped = false
+
+ for (let index = 0; index < value.length; index++) {
+ const current = value[index]
+ const next = value[index + 1]
+
+ if (inString) {
+ result += current
+
+ if (escaped) {
+ escaped = false
+ continue
+ }
+
+ if (current === "\\") {
+ escaped = true
+ continue
+ }
+
+ if (current === '"') {
+ inString = false
+ }
+
+ continue
+ }
+
+ if (current === '"') {
+ inString = true
+ result += current
+ continue
+ }
+
+ if (current === "/" && next === "/") {
+ while (index < value.length && value[index] !== "\n") {
+ index++
+ }
+
+ if (index < value.length) {
+ result += value[index]
+ }
+
+ continue
+ }
+
+ if (current === "/" && next === "*") {
+ index += 2
+
+ while (index < value.length) {
+ if (value[index] === "*" && value[index + 1] === "/") {
+ index++
+ break
+ }
+
+ if (value[index] === "\n") {
+ result += "\n"
+ }
+
+ index++
+ }
+
+ continue
+ }
+
+ if (current === ",") {
+ let nextIndex = index + 1
+
+ while (nextIndex < value.length) {
+ while (/\s/.test(value[nextIndex] ?? "")) {
+ nextIndex++
+ }
+
+ if (value[nextIndex] === "/" && value[nextIndex + 1] === "/") {
+ nextIndex += 2
+
+ while (
+ nextIndex < value.length &&
+ value[nextIndex] !== "\n" &&
+ value[nextIndex] !== "\r"
+ ) {
+ nextIndex++
+ }
+
+ continue
+ }
+
+ if (value[nextIndex] === "/" && value[nextIndex + 1] === "*") {
+ nextIndex += 2
+
+ while (
+ nextIndex < value.length &&
+ !(value[nextIndex] === "*" && value[nextIndex + 1] === "/")
+ ) {
+ nextIndex++
+ }
+
+ nextIndex += 2
+ continue
+ }
+
+ break
+ }
+
+ if (value[nextIndex] === "}" || value[nextIndex] === "]") {
+ continue
+ }
+ }
+
+ result += current
+ }
+
+ return result
+}
+
export async function getProjectConfig(
cwd: string,
defaultProjectInfo: ProjectInfo | null = null
): Promise {
// Check for existing component config.
- const [existingConfig, projectInfo] = await Promise.all([
+ const [existingConfig, projectInfo, aliasInfo] = await Promise.all([
getConfig(cwd),
!defaultProjectInfo
? getProjectInfo(cwd)
: Promise.resolve(defaultProjectInfo),
+ getProjectAliasInfo(cwd),
])
if (existingConfig) {
@@ -384,6 +545,35 @@ export async function getProjectConfig(
return null
}
+ const packageImportAliases =
+ aliasInfo.source === "package_imports" ? getPackageImportAliases(cwd) : null
+
+ if (!projectInfo.aliasPrefix) {
+ return null
+ }
+
+ const fallbackAliases = getAliasDefaultsFromPrefix(
+ projectInfo.aliasPrefix,
+ aliasInfo.source === "package_imports"
+ )
+
+ const aliases =
+ aliasInfo.source === "package_imports" && packageImportAliases
+ ? derivePackageImportAliases({
+ ...fallbackAliases,
+ components:
+ packageImportAliases.components ?? fallbackAliases.components,
+ ui: packageImportAliases.ui ?? fallbackAliases.ui,
+ hooks: packageImportAliases.hooks ?? fallbackAliases.hooks,
+ lib: packageImportAliases.lib ?? fallbackAliases.lib,
+ utils: packageImportAliases.utils ?? fallbackAliases.utils,
+ })
+ : fallbackAliases
+
+ if (!aliases.components || !aliases.utils) {
+ return null
+ }
+
const config: z.infer = {
$schema: "https://ui.shadcn.com/schema.json",
rsc: projectInfo.isRSC,
@@ -397,18 +587,59 @@ export async function getProjectConfig(
prefix: "",
},
iconLibrary: "lucide",
- aliases: {
- components: `${projectInfo.aliasPrefix}/components`,
- ui: `${projectInfo.aliasPrefix}/components/ui`,
- hooks: `${projectInfo.aliasPrefix}/hooks`,
- lib: `${projectInfo.aliasPrefix}/lib`,
- utils: `${projectInfo.aliasPrefix}/lib/utils`,
- },
+ aliases,
}
return await resolveConfigPaths(cwd, config)
}
+function getAliasDefaultsFromPrefix(
+ aliasPrefix: string,
+ isPackageImport: boolean = false
+) {
+ if (isPackageImport && aliasPrefix === "#") {
+ return {
+ components: "",
+ ui: undefined,
+ hooks: undefined,
+ lib: undefined,
+ utils: "",
+ }
+ }
+
+ return {
+ components: `${aliasPrefix}/components`,
+ ui: `${aliasPrefix}/components/ui`,
+ hooks: `${aliasPrefix}/hooks`,
+ lib: `${aliasPrefix}/lib`,
+ utils: `${aliasPrefix}/lib/utils`,
+ }
+}
+
+function derivePackageImportAliases(aliases: {
+ components: string
+ ui?: string
+ hooks?: string
+ lib?: string
+ utils: string
+}) {
+ const derivedAliases = { ...aliases }
+
+ if (!derivedAliases.ui && derivedAliases.components) {
+ derivedAliases.ui = `${derivedAliases.components}/ui`
+ }
+
+ if (!derivedAliases.lib && derivedAliases.utils.endsWith("/utils")) {
+ derivedAliases.lib = derivedAliases.utils.slice(0, -"/utils".length)
+ }
+
+ if (!derivedAliases.utils && derivedAliases.lib) {
+ derivedAliases.utils = `${derivedAliases.lib}/utils`
+ }
+
+ return derivedAliases
+}
+
export async function getProjectTailwindVersionFromConfig(config: {
resolvedPaths: Pick
}): Promise {
diff --git a/packages/shadcn/src/utils/import-matcher.ts b/packages/shadcn/src/utils/import-matcher.ts
new file mode 100644
index 0000000000..59d1f23f21
--- /dev/null
+++ b/packages/shadcn/src/utils/import-matcher.ts
@@ -0,0 +1,156 @@
+import path from "path"
+
+// Node can resolve `package.json#imports` and `package.json#exports` at
+// runtime, but the CLI needs the matched pattern, local filesystem target, and
+// emit behavior as data so it can place files and rewrite imports consistently.
+// This module is the shared matcher for those normalized entry shapes.
+
+export type ImportEmitMode = "strip_extension" | "preserve_extension"
+
+export type ImportResolutionEntry = {
+ key: string
+ aliasBase: string
+ target: string
+ emitMode: ImportEmitMode
+ hasWildcard: boolean
+ rootDir: string
+}
+
+export type ImportResolutionMatch = {
+ path: string
+ matchedAlias: string
+ matchedTarget: string
+ emitMode: ImportEmitMode
+}
+
+export function resolveLocalPathTarget(target: unknown) {
+ const queue = [target]
+
+ while (queue.length) {
+ const value = queue.shift()
+
+ if (typeof value === "string") {
+ if (value.startsWith("./")) {
+ return value
+ }
+
+ continue
+ }
+
+ if (Array.isArray(value)) {
+ queue.unshift(...value)
+ continue
+ }
+
+ if (value && typeof value === "object") {
+ queue.unshift(...Object.values(value as Record))
+ }
+ }
+
+ return null
+}
+
+export function getImportTargetEmitMode(target: string) {
+ if (!target.includes("*")) {
+ return "strip_extension"
+ }
+
+ const suffix = target.slice(target.indexOf("*") + 1)
+
+ // A bare `*` target like `./src/components/*` expects the emitted specifier
+ // to include the source extension (`#components/button.tsx`).
+ if (!suffix) {
+ return "preserve_extension"
+ }
+
+ return /^\.[^/]+$/.test(suffix) ? "strip_extension" : "preserve_extension"
+}
+
+export function resolveImportEntryMatch(
+ importPath: string,
+ entries: ImportResolutionEntry[]
+) {
+ const exactMatch = entries.find(
+ (entry) => !entry.hasWildcard && entry.key === importPath
+ )
+
+ if (exactMatch) {
+ return {
+ path: path.resolve(exactMatch.rootDir, exactMatch.target),
+ matchedAlias: exactMatch.key,
+ matchedTarget: exactMatch.target,
+ emitMode: exactMatch.emitMode,
+ }
+ }
+
+ const wildcardMatches = entries
+ .filter((entry) => entry.hasWildcard)
+ .sort((a, b) => b.key.length - a.key.length)
+
+ for (const entry of wildcardMatches) {
+ const wildcardValue = getPatternWildcardValue(importPath, entry.key, {
+ allowBareAliasBase: true,
+ })
+
+ if (wildcardValue === null) {
+ continue
+ }
+
+ return {
+ path: path.resolve(
+ entry.rootDir,
+ applyWildcardTarget(entry.target, wildcardValue)
+ ),
+ matchedAlias: entry.key,
+ matchedTarget: entry.target,
+ emitMode: entry.emitMode,
+ }
+ }
+
+ return null
+}
+
+export function getPatternWildcardValue(
+ importPath: string,
+ pattern: string,
+ options: {
+ allowBareAliasBase?: boolean
+ } = {}
+) {
+ if (!pattern.includes("*")) {
+ return importPath === pattern ? "" : null
+ }
+
+ const [prefix, suffix = ""] = pattern.split("*")
+
+ if (importPath.startsWith(prefix) && importPath.endsWith(suffix)) {
+ return suffix
+ ? importPath.slice(prefix.length, -suffix.length)
+ : importPath.slice(prefix.length)
+ }
+
+ if (
+ options.allowBareAliasBase &&
+ suffix === "" &&
+ prefix.endsWith("/") &&
+ importPath === prefix.slice(0, -1)
+ ) {
+ return ""
+ }
+
+ return null
+}
+
+export function applyWildcardTarget(target: string, wildcardValue: string) {
+ if (!target.includes("*")) {
+ return target
+ }
+
+ const [prefix, suffix = ""] = target.split("*")
+
+ if (!wildcardValue) {
+ return prefix.replace(/\/$/, "")
+ }
+
+ return `${prefix}${wildcardValue}${suffix}`
+}
diff --git a/packages/shadcn/src/utils/package-imports.ts b/packages/shadcn/src/utils/package-imports.ts
new file mode 100644
index 0000000000..f6d3dbe41a
--- /dev/null
+++ b/packages/shadcn/src/utils/package-imports.ts
@@ -0,0 +1,185 @@
+import path from "path"
+import { getPackageInfo } from "@/src/utils/get-package-info"
+import {
+ getImportTargetEmitMode,
+ resolveImportEntryMatch,
+ resolveLocalPathTarget,
+ type ImportEmitMode,
+ type ImportResolutionEntry,
+ type ImportResolutionMatch,
+} from "@/src/utils/import-matcher"
+
+export type { ImportEmitMode } from "@/src/utils/import-matcher"
+export type PackageImportEntry = ImportResolutionEntry
+export type PackageImportMatch = ImportResolutionMatch
+
+const packageImportEntriesCache = new Map()
+
+export function getPackageImportEntries(cwd: string) {
+ const cacheKey = path.resolve(cwd)
+ const cachedEntries = packageImportEntriesCache.get(cacheKey)
+
+ if (cachedEntries) {
+ return cachedEntries
+ }
+
+ const packageInfo = getPackageInfo(cwd, false)
+ const imports = packageInfo?.imports
+
+ if (!imports || typeof imports !== "object" || Array.isArray(imports)) {
+ packageImportEntriesCache.set(cacheKey, [])
+ return []
+ }
+
+ const entries: PackageImportEntry[] = []
+
+ for (const [key, value] of Object.entries(imports)) {
+ if (!key.startsWith("#")) {
+ continue
+ }
+
+ const target = resolveLocalPathTarget(value)
+ if (!target) {
+ continue
+ }
+
+ entries.push({
+ key,
+ aliasBase:
+ key === "#*" ? "#" : key.endsWith("/*") ? key.slice(0, -2) : key,
+ target,
+ emitMode: getImportTargetEmitMode(target),
+ hasWildcard: key.includes("*"),
+ rootDir: cacheKey,
+ })
+ }
+
+ packageImportEntriesCache.set(cacheKey, entries)
+ return entries
+}
+
+export function getPackageImportPrefix(cwd: string) {
+ const aliases = getPackageImportEntries(cwd).map((entry) => entry.aliasBase)
+
+ if (!aliases.length) {
+ return null
+ }
+
+ return getSharedPackageImportPrefix(aliases)
+}
+
+export function resolvePackageImport(importPath: string, cwd: string) {
+ return resolveImportEntryMatch(importPath, getPackageImportEntries(cwd))
+}
+
+export function getPackageImportAliases(cwd: string) {
+ const entries = getPackageImportEntries(cwd)
+ const rootWildcardDefaults = entries.some((entry) => entry.key === "#*")
+ ? {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#lib/utils",
+ }
+ : null
+
+ return {
+ components:
+ findBestAlias(entries, "components") ?? rootWildcardDefaults?.components,
+ ui: findBestAlias(entries, "ui") ?? rootWildcardDefaults?.ui,
+ lib: findBestAlias(entries, "lib") ?? rootWildcardDefaults?.lib,
+ hooks: findBestAlias(entries, "hooks") ?? rootWildcardDefaults?.hooks,
+ utils: findBestAlias(entries, "utils") ?? rootWildcardDefaults?.utils,
+ }
+}
+
+function findBestAlias(
+ entries: PackageImportEntry[],
+ kind: "components" | "ui" | "lib" | "hooks" | "utils"
+) {
+ const matches = entries
+ .map((entry) => ({
+ entry,
+ score: getAliasScore(entry, kind),
+ }))
+ .filter((match) => match.score > 0)
+ .sort(
+ (a, b) =>
+ b.score - a.score || b.entry.aliasBase.length - a.entry.aliasBase.length
+ )
+
+ return matches[0]?.entry.aliasBase
+}
+
+function getAliasScore(
+ entry: PackageImportEntry,
+ kind: "components" | "ui" | "lib" | "hooks" | "utils"
+) {
+ const aliasBase = entry.aliasBase.toLowerCase()
+ const normalizedTarget = normalizeTarget(entry.target).toLowerCase()
+
+ switch (kind) {
+ case "components":
+ if (
+ aliasBase.endsWith("/ui") ||
+ normalizedTarget.includes("/components/ui")
+ ) {
+ return 0
+ }
+ if (includesPathSegment(aliasBase, "components")) return 4
+ if (includesPathSegment(normalizedTarget, "components")) return 3
+ return 0
+ case "ui":
+ if (aliasBase.endsWith("/ui") || aliasBase === "#ui") return 5
+ if (normalizedTarget.includes("/components/ui")) return 4
+ if (normalizedTarget.endsWith("/ui")) return 3
+ return 0
+ case "lib":
+ if (aliasBase === "#lib" || aliasBase.endsWith("/lib")) return 5
+ if (normalizedTarget.endsWith("/lib")) return 4
+ if (includesPathSegment(normalizedTarget, "lib")) return 3
+ return 0
+ case "hooks":
+ if (aliasBase === "#hooks" || aliasBase.endsWith("/hooks")) return 5
+ if (normalizedTarget.endsWith("/hooks")) return 4
+ if (includesPathSegment(normalizedTarget, "hooks")) return 3
+ return 0
+ case "utils":
+ if (aliasBase === "#utils" || aliasBase.endsWith("/utils")) return 5
+ if (normalizedTarget.endsWith("/lib/utils")) return 4
+ if (normalizedTarget.endsWith("/utils")) return 3
+ return 0
+ }
+}
+
+function normalizeTarget(target: string) {
+ return target
+ .replace(/\/\*$/, "")
+ .replace(/\*$/, "")
+ .replace(/\/index\.[^/]+$/, "")
+}
+
+function includesPathSegment(value: string, segment: string) {
+ return (
+ value === segment ||
+ value.includes(`/${segment}`) ||
+ value.includes(`${segment}/`)
+ )
+}
+
+function getSharedPackageImportPrefix(aliasBases: string[]) {
+ const sharedSegments = aliasBases
+ .map((aliasBase) => aliasBase.slice(1).split("/").filter(Boolean))
+ .reduce((shared, segments, index) => {
+ if (!index) {
+ return segments
+ }
+
+ return shared.filter((segment, segmentIndex) => {
+ return segments[segmentIndex] === segment
+ })
+ }, [])
+
+ return sharedSegments.length ? `#${sharedSegments.join("/")}` : "#"
+}
diff --git a/packages/shadcn/src/utils/resolve-import.ts b/packages/shadcn/src/utils/resolve-import.ts
index d8da91a379..7a606fc787 100644
--- a/packages/shadcn/src/utils/resolve-import.ts
+++ b/packages/shadcn/src/utils/resolve-import.ts
@@ -1,13 +1,139 @@
+import { getPatternWildcardValue } from "@/src/utils/import-matcher"
+import {
+ resolvePackageImport,
+ type ImportEmitMode,
+} from "@/src/utils/package-imports"
+import { resolveWorkspacePackageExport } from "@/src/utils/workspace"
import { createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths"
+export type ResolvedImport = {
+ path: string
+ source: "tsconfig_paths" | "package_imports" | "workspace_package_exports"
+ matchedAlias: string
+ matchedTarget: string
+ emitMode: ImportEmitMode
+}
+
+type ResolveImportConfig = Pick<
+ ConfigLoaderSuccessResult,
+ "absoluteBaseUrl" | "paths"
+> & {
+ cwd?: string
+}
+
+export async function resolveImportWithMetadata(
+ importPath: string,
+ config: ResolveImportConfig
+) {
+ const cwd = config.cwd ?? config.absoluteBaseUrl
+
+ if (importPath.startsWith("#")) {
+ const resolved = resolvePackageImport(importPath, cwd)
+
+ if (resolved) {
+ return {
+ path: resolved.path,
+ source: "package_imports",
+ matchedAlias: resolved.matchedAlias,
+ matchedTarget: resolved.matchedTarget,
+ emitMode: resolved.emitMode,
+ } satisfies ResolvedImport
+ }
+ }
+
+ const workspaceResolved = await resolveWorkspacePackageExport(importPath, cwd)
+
+ if (workspaceResolved) {
+ return {
+ path: workspaceResolved.path,
+ source: "workspace_package_exports",
+ matchedAlias: workspaceResolved.matchedAlias,
+ matchedTarget: workspaceResolved.matchedTarget,
+ emitMode: workspaceResolved.emitMode,
+ } satisfies ResolvedImport
+ }
+
+ return resolveFromTsconfigPaths(importPath, config)
+}
+
export async function resolveImport(
importPath: string,
- config: Pick
+ config: ResolveImportConfig
) {
- return createMatchPath(config.absoluteBaseUrl, config.paths)(
+ return (await resolveImportWithMetadata(importPath, config))?.path ?? null
+}
+
+export function isLocalAliasImport(
+ moduleSpecifier: string,
+ aliasPrefix: string | null
+) {
+ // Workspace package exports such as `@workspace/ui/...` are already the final
+ // import specifiers we want to keep, so they are intentionally excluded here.
+ if (moduleSpecifier.startsWith("#")) {
+ return true
+ }
+
+ if (!aliasPrefix) {
+ return false
+ }
+
+ return moduleSpecifier.startsWith(`${aliasPrefix}/`)
+}
+
+function isScopedPackageSpecifier(importPath: string) {
+ return /^@[^/]+\/[^/]+(?:\/.*)?$/.test(importPath)
+}
+
+function resolveFromTsconfigPaths(
+ importPath: string,
+ config: ResolveImportConfig
+) {
+ const matchedPath = createMatchPath(config.absoluteBaseUrl, config.paths)(
importPath,
undefined,
() => true,
[".ts", ".tsx", ".jsx", ".js", ".css"]
)
+
+ if (!matchedPath) {
+ return null
+ }
+
+ const matchedPattern = findMatchingTsPathPattern(importPath, config.paths)
+
+ if (!matchedPattern && isScopedPackageSpecifier(importPath)) {
+ return null
+ }
+
+ return {
+ path: matchedPath,
+ source: "tsconfig_paths",
+ matchedAlias: matchedPattern?.key ?? importPath,
+ matchedTarget: matchedPattern?.target ?? matchedPath,
+ emitMode: "strip_extension",
+ }
+}
+
+function findMatchingTsPathPattern(
+ importPath: string,
+ paths: ConfigLoaderSuccessResult["paths"]
+) {
+ for (const [key, targets] of Object.entries(paths)) {
+ const targetList = Array.isArray(targets) ? targets : [targets]
+ const wildcardValue = getPatternWildcardValue(importPath, key)
+
+ if (wildcardValue === null) {
+ continue
+ }
+
+ return {
+ key,
+ target:
+ targetList[0]?.includes("*") && wildcardValue !== null
+ ? targetList[0].replace(/\*/g, wildcardValue)
+ : targetList[0],
+ }
+ }
+
+ return null
}
diff --git a/packages/shadcn/src/utils/transformers/transform-import.ts b/packages/shadcn/src/utils/transformers/transform-import.ts
index 59177f9f78..ecd5e15e63 100644
--- a/packages/shadcn/src/utils/transformers/transform-import.ts
+++ b/packages/shadcn/src/utils/transformers/transform-import.ts
@@ -9,10 +9,12 @@ export const transformImport: Transformer = async ({
}) => {
const utilsAlias = config.aliases?.utils
const workspaceAlias =
- typeof utilsAlias === "string" && utilsAlias.includes("/")
- ? utilsAlias.split("/")[0]
+ typeof utilsAlias === "string"
+ ? getWorkspaceAliasFromUtilsAlias(utilsAlias)
: "@"
- const utilsImport = `${workspaceAlias}/lib/utils`
+ const utilsImport = workspaceAlias
+ ? `${workspaceAlias}/lib/utils`
+ : "@/lib/utils"
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
return sourceFile
@@ -55,6 +57,8 @@ function updateImportAliases(
config: Config,
isRemote: boolean = false
) {
+ moduleSpecifier = normalizeImportSpecifier(moduleSpecifier, config)
+
// Not a local import.
if (!moduleSpecifier.startsWith("@/") && !isRemote) {
return moduleSpecifier
@@ -65,9 +69,41 @@ function updateImportAliases(
moduleSpecifier = moduleSpecifier.replace(/^@\//, `@/registry/new-york/`)
}
+ if (moduleSpecifier === "@/registry") {
+ return config.aliases.components
+ }
+
// Not a registry import.
if (!moduleSpecifier.startsWith("@/registry/")) {
- // We fix the alias and return.
+ if (moduleSpecifier === "@/lib/utils" && config.aliases.utils) {
+ return config.aliases.utils
+ }
+
+ if (
+ config.aliases.ui &&
+ moduleSpecifier.match(/^@\/components\/ui(?=\/|$)/)
+ ) {
+ return moduleSpecifier.replace(/^@\/components\/ui/, config.aliases.ui)
+ }
+
+ if (
+ config.aliases.components &&
+ moduleSpecifier.match(/^@\/components(?=\/|$)/)
+ ) {
+ return moduleSpecifier.replace(
+ /^@\/components/,
+ config.aliases.components
+ )
+ }
+
+ if (config.aliases.hooks && moduleSpecifier.match(/^@\/hooks(?=\/|$)/)) {
+ return moduleSpecifier.replace(/^@\/hooks/, config.aliases.hooks)
+ }
+
+ if (config.aliases.lib && moduleSpecifier.match(/^@\/lib(?=\/|$)/)) {
+ return moduleSpecifier.replace(/^@\/lib/, config.aliases.lib)
+ }
+
const alias = config.aliases.components.split("/")[0]
return moduleSpecifier.replace(/^@\//, `${alias}/`)
}
@@ -79,6 +115,13 @@ function updateImportAliases(
)
}
+ if (
+ config.aliases.utils &&
+ moduleSpecifier.match(/^@\/registry\/(.+)\/lib\/utils$/)
+ ) {
+ return config.aliases.utils
+ }
+
if (
config.aliases.components &&
moduleSpecifier.match(/^@\/registry\/(.+)\/components/)
@@ -111,3 +154,71 @@ function updateImportAliases(
config.aliases.components
)
}
+
+function getWorkspaceAliasFromUtilsAlias(utilsAlias: string) {
+ // `#...` utils aliases are handled by package-import normalization and should
+ // not be treated as workspace package roots.
+ if (utilsAlias.startsWith("#")) {
+ return ""
+ }
+
+ if (utilsAlias.endsWith("/lib/utils")) {
+ return utilsAlias.slice(0, -"/lib/utils".length)
+ }
+
+ if (utilsAlias.startsWith("@")) {
+ const [scope, name] = utilsAlias.split("/")
+ return scope && name ? `${scope}/${name}` : utilsAlias
+ }
+
+ const slashIndex = utilsAlias.indexOf("/")
+ return slashIndex === -1 ? utilsAlias : utilsAlias.slice(0, slashIndex)
+}
+
+function normalizeImportSpecifier(moduleSpecifier: string, config: Config) {
+ if (moduleSpecifier === "#registry") {
+ return "@/registry"
+ }
+
+ if (moduleSpecifier.startsWith("#/")) {
+ return moduleSpecifier.replace(/^#\//, "@/")
+ }
+
+ if (moduleSpecifier.startsWith("#registry/")) {
+ return moduleSpecifier.replace(/^#registry\//, "@/registry/")
+ }
+
+ // We only normalize the standard shadcn alias slots here so the rest of the
+ // transformer can keep operating on the canonical `@/...` forms it already
+ // understands.
+ for (const { alias, normalized } of getConfigAliasNormalizations(config)) {
+ if (moduleSpecifier === alias) {
+ return normalized
+ }
+
+ if (moduleSpecifier.startsWith(`${alias}/`)) {
+ return `${normalized}${moduleSpecifier.slice(alias.length)}`
+ }
+ }
+
+ return moduleSpecifier
+}
+
+function getConfigAliasNormalizations(config: Config) {
+ if (!config.aliases) {
+ return []
+ }
+
+ return [
+ { alias: config.aliases.ui, normalized: "@/components/ui" },
+ { alias: config.aliases.components, normalized: "@/components" },
+ { alias: config.aliases.hooks, normalized: "@/hooks" },
+ { alias: config.aliases.lib, normalized: "@/lib" },
+ { alias: config.aliases.utils, normalized: "@/lib/utils" },
+ ]
+ .filter(
+ (entry): entry is { alias: string; normalized: string } =>
+ typeof entry.alias === "string" && entry.alias.startsWith("#")
+ )
+ .sort((a, b) => b.alias.length - a.alias.length)
+}
diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts
index adb96574f4..bf1bf7a08b 100644
--- a/packages/shadcn/src/utils/updaters/update-files.ts
+++ b/packages/shadcn/src/utils/updaters/update-files.ts
@@ -15,7 +15,11 @@ import { Config } from "@/src/utils/get-config"
import { getProjectInfo, ProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
-import { resolveImport } from "@/src/utils/resolve-import"
+import { resolvePackageImport } from "@/src/utils/package-imports"
+import {
+ isLocalAliasImport,
+ resolveImportWithMetadata,
+} from "@/src/utils/resolve-import"
import { spinner } from "@/src/utils/spinner"
import { transform } from "@/src/utils/transformers"
import { transformAsChild } from "@/src/utils/transformers/transform-aschild"
@@ -31,9 +35,12 @@ import { transformRtl } from "@/src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import prompts from "prompts"
import { Project, ScriptKind } from "ts-morph"
-import { loadConfig } from "tsconfig-paths"
+import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { z } from "zod"
+const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]
+const NON_ALIAS_RESOLVED_PATH_KEYS = new Set(["tailwindConfig", "tailwindCss"])
+
export async function updateFiles(
files: RegistryItem["files"],
config: Config,
@@ -45,6 +52,7 @@ export async function updateFiles(
isRemote?: boolean
isWorkspace?: boolean
path?: string
+ plannedFiles?: RegistryItem["files"]
supportedFontMarkers?: string[]
}
) {
@@ -73,6 +81,19 @@ export async function updateFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
+ const tsConfig = loadConfig(config.resolvedPaths.cwd)
+ const plannedFilePaths = getPlannedFilePaths(
+ options.plannedFiles ?? files,
+ config,
+ {
+ isSrcDir: projectInfo?.isSrcDir,
+ framework: projectInfo?.framework.name,
+ path: options.path,
+ }
+ )
+ const importRewriteProject = new Project({
+ compilerOptions: {},
+ })
let filesCreated: string[] = []
let filesUpdated: string[] = []
@@ -176,10 +197,19 @@ export async function updateFiles(
// Skip the file if it already exists and the content is the same.
// Exception: Don't skip .env files as we merge content instead of replacing
if (existingFile && !isEnvFile(filePath)) {
+ const resolvedContent = await rewriteResolvedImportsInContent({
+ config,
+ content,
+ filePaths: plannedFilePaths,
+ project: importRewriteProject,
+ projectInfo,
+ resolvedPath: filePath,
+ tsConfig,
+ })
const existingFileContent = await fs.readFile(filePath, "utf-8")
if (
- isContentSame(existingFileContent, content, {
+ isContentSame(existingFileContent, resolvedContent, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
@@ -261,7 +291,7 @@ export async function updateFiles(
}
const allFiles = [...filesCreated, ...filesUpdated, ...filesSkipped]
- const updatedFiles = await resolveImports(allFiles, config)
+ const updatedFiles = await resolveImports(allFiles, config, plannedFilePaths)
// Let's update filesUpdated with the updated files.
filesUpdated.push(...updatedFiles)
@@ -519,7 +549,11 @@ export function resolvePageTarget(
return ""
}
-async function resolveImports(filePaths: string[], config: Config) {
+async function resolveImports(
+ filePaths: string[],
+ config: Config,
+ plannedFilePaths: string[] = filePaths
+) {
const project = new Project({
compilerOptions: {},
})
@@ -554,47 +588,138 @@ async function resolveImports(filePaths: string[], config: Config) {
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
continue
}
+ const rewrittenContent = await rewriteResolvedImportsInContent({
+ config,
+ content,
+ filePaths: plannedFilePaths,
+ project,
+ projectInfo,
+ resolvedPath,
+ sourceFile,
+ tsConfig,
+ })
- const importDeclarations = sourceFile.getImportDeclarations()
- for (const importDeclaration of importDeclarations) {
+ if (rewrittenContent === content) {
+ continue
+ }
+
+ await fs.writeFile(resolvedPath, rewrittenContent, "utf-8")
+ updatedFiles.push(filepath)
+ }
+
+ return updatedFiles
+}
+
+export function getPlannedFilePaths(
+ files: RegistryItem["files"],
+ config: Config,
+ options: {
+ isSrcDir?: boolean
+ framework?: ProjectInfo["framework"]["name"]
+ path?: string
+ }
+) {
+ return (files ?? [])
+ ?.filter((file): file is NonNullable => !!file?.content)
+ .map((file, index) => {
+ let filePath = resolveFilePath(file, config, {
+ isSrcDir: options.isSrcDir,
+ framework: options.framework,
+ commonRoot: findCommonRoot(
+ (files ?? []).map((entry) => entry.path),
+ file.path
+ ),
+ path: options.path,
+ fileIndex: index,
+ })
+
+ if (!filePath) {
+ return null
+ }
+
+ if (!config.tsx) {
+ filePath = filePath.replace(/\.tsx?$/, (match) =>
+ match === ".tsx" ? ".jsx" : ".js"
+ )
+ }
+
+ return path.relative(config.resolvedPaths.cwd, filePath)
+ })
+ .filter((filePath): filePath is string => !!filePath)
+}
+
+export async function rewriteResolvedImportsInContent({
+ content,
+ resolvedPath,
+ filePaths,
+ config,
+ projectInfo,
+ tsConfig,
+ project,
+ sourceFile,
+}: {
+ content: string
+ resolvedPath: string
+ filePaths: string[]
+ config: Config
+ projectInfo: ProjectInfo | null
+ tsConfig: ReturnType
+ project: Project
+ sourceFile?: ReturnType
+}) {
+ if (!projectInfo || tsConfig.resultType === "failed") {
+ return content
+ }
+
+ const ext = path.extname(resolvedPath)
+ if (![".tsx", ".ts", ".jsx", ".js"].includes(ext)) {
+ return content
+ }
+
+ const createdSourceFile =
+ sourceFile === undefined
+ ? project.createSourceFile(
+ path.join(
+ tmpdir(),
+ `shadcn-${Math.random().toString(36).slice(2)}${ext || ".tsx"}`
+ ),
+ content,
+ {
+ scriptKind: ScriptKind.TSX,
+ overwrite: true,
+ }
+ )
+ : null
+ const workingSourceFile = sourceFile ?? createdSourceFile!
+
+ try {
+ let hasChanges = false
+
+ for (const importDeclaration of workingSourceFile.getImportDeclarations()) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
- // Filter out non-local imports.
if (
- projectInfo?.aliasPrefix &&
- !moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`)
+ !isLocalAliasImport(moduleSpecifier, projectInfo.aliasPrefix ?? null)
) {
continue
}
- // Find the probable import file path.
- // This is where we expect to find the file on disk.
- const probableImportFilePath = await resolveImport(
+ const resolvedImportFilePath = await resolveImportFilePathForRewrite(
moduleSpecifier,
- tsConfig
- )
-
- if (!probableImportFilePath) {
- continue
- }
-
- // Find the actual import file path.
- // This is the path where the file has been installed.
- const resolvedImportFilePath = resolveModuleByProbablePath(
- probableImportFilePath,
filePaths,
- config
+ config,
+ tsConfig
)
if (!resolvedImportFilePath) {
continue
}
- // Convert the resolved import file path to an aliased import.
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
- projectInfo
+ projectInfo,
+ resolvedPath
)
if (!newImport || newImport === moduleSpecifier) {
@@ -602,16 +727,44 @@ async function resolveImports(filePaths: string[], config: Config) {
}
importDeclaration.setModuleSpecifier(newImport)
+ hasChanges = true
+ }
- // Write the updated content to the file.
- await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8")
-
- // Track the updated file.
- updatedFiles.push(filepath)
+ return hasChanges ? workingSourceFile.getFullText() : content
+ } finally {
+ if (createdSourceFile) {
+ project.removeSourceFile(createdSourceFile)
}
}
+}
- return updatedFiles
+async function resolveImportFilePathForRewrite(
+ moduleSpecifier: string,
+ filePaths: string[],
+ config: Config,
+ tsConfig: Pick
+) {
+ const probableImportFilePath = (
+ await resolveImportWithMetadata(moduleSpecifier, {
+ ...tsConfig,
+ cwd: config.resolvedPaths.cwd,
+ })
+ )?.path
+
+ const fallbackImportFilePath =
+ !probableImportFilePath && !moduleSpecifier.startsWith(".")
+ ? resolveImportFromConfiguredAliases(moduleSpecifier, config)
+ : null
+
+ if (!probableImportFilePath && !fallbackImportFilePath) {
+ return null
+ }
+
+ return resolveModuleByProbablePath(
+ probableImportFilePath ?? fallbackImportFilePath!,
+ filePaths,
+ config
+ )
}
/**
@@ -694,16 +847,40 @@ export function resolveModuleByProbablePath(
export function toAliasedImport(
filePath: string,
config: Config,
- projectInfo: ProjectInfo
+ projectInfo: ProjectInfo,
+ importerPath?: string
): string | null {
const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath))
// 1️⃣ Find the longest matching alias root in resolvedPaths
// e.g. key="ui", root="/…/components/ui" beats key="components"
const matches = Object.entries(config.resolvedPaths)
- .filter(
- ([, root]) => root && abs.startsWith(path.normalize(root + path.sep))
- )
+ .filter(([key, root]) => {
+ if (!root || NON_ALIAS_RESOLVED_PATH_KEYS.has(key)) {
+ return false
+ }
+
+ const normalizedRoot = path.normalize(root)
+
+ if (abs === normalizedRoot) {
+ // Only allow exact-equality match for true exact-key package imports
+ // (e.g. `#utils` → `./src/lib/utils.ts`). Path-style aliases that
+ // resolve through a wildcard (e.g. `#lib/utils` via `#lib/*`) must
+ // fall back to the directory alias so the wildcard's emit-mode
+ // (preserve/strip extension) is honored.
+ const aliasValue = config.aliases[key as keyof typeof config.aliases]
+ if (typeof aliasValue !== "string" || !aliasValue.startsWith("#")) {
+ return true
+ }
+ const resolved = resolvePackageImport(
+ aliasValue,
+ config.resolvedPaths.cwd
+ )
+ return resolved !== null && !resolved.matchedAlias.includes("*")
+ }
+
+ return abs.startsWith(path.normalize(root + path.sep))
+ })
.sort((a, b) => b[1].length - a[1].length)
if (matches.length === 0) {
@@ -716,10 +893,31 @@ export function toAliasedImport(
// force POSIX-style separators
rel = rel.split(path.sep).join("/") // e.g. "button/index.tsx"
+ const aliasBase =
+ aliasKey === "cwd"
+ ? projectInfo.aliasPrefix
+ : config.aliases[aliasKey as keyof typeof config.aliases]
+ if (!aliasBase) {
+ return null
+ }
+
+ if (aliasBase.startsWith("#")) {
+ const packageImport = resolvePackageImport(
+ aliasBase,
+ config.resolvedPaths.cwd
+ )
+
+ if (packageImport) {
+ return (
+ toPackageImport(aliasBase, rel, packageImport) ??
+ (importerPath ? toRelativeImport(importerPath, abs) : null)
+ )
+ }
+ }
+
// 3️⃣ Strip code-file extensions, keep others (css, json, etc.)
const ext = path.posix.extname(rel)
- const codeExts = [".ts", ".tsx", ".js", ".jsx"]
- const keepExt = codeExts.includes(ext) ? "" : ext
+ const keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
// 4️⃣ Collapse "/index" to its directory
@@ -728,26 +926,138 @@ export function toAliasedImport(
}
// 5️⃣ Build the aliased path
- // config.aliases[aliasKey] is e.g. "@/components/ui"
- const aliasBase =
- aliasKey === "cwd"
- ? projectInfo.aliasPrefix
- : config.aliases[aliasKey as keyof typeof config.aliases]
- if (!aliasBase) {
- return null
- }
- // if noExt is empty (i.e. file was exactly at the root), we import the root
let suffix = noExt === "" ? "" : `/${noExt}`
// Remove /src from suffix.
// Alias will handle this.
suffix = suffix.replace("/src", "")
- // 6️⃣ Prepend the prefix from projectInfo (e.g. "@") if needed
- // but usually config.aliases already include it.
return `${aliasBase}${suffix}${keepExt}`
}
+function toPackageImport(
+ aliasBase: string,
+ relativePath: string,
+ packageImport: ReturnType extends infer T
+ ? Exclude
+ : never
+) {
+ const ext = path.posix.extname(relativePath)
+ const keepExt =
+ CODE_EXTENSIONS.includes(ext) &&
+ packageImport.emitMode === "strip_extension"
+ ? ""
+ : ext
+ const normalizedRelativePath = relativePath
+ ? relativePath.slice(0, relativePath.length - ext.length) + keepExt
+ : ""
+
+ if (!packageImport.matchedAlias.includes("*")) {
+ return normalizedRelativePath === "" || normalizedRelativePath === "index"
+ ? aliasBase
+ : null
+ }
+
+ return normalizedRelativePath
+ ? `${aliasBase}/${normalizedRelativePath}`
+ : aliasBase
+}
+
+function resolveImportFromConfiguredAliases(
+ moduleSpecifier: string,
+ config: Config
+) {
+ const aliasEntries = getConfiguredAliasEntries(config)
+
+ for (const entry of aliasEntries) {
+ if (
+ moduleSpecifier === entry.alias ||
+ moduleSpecifier === entry.canonical
+ ) {
+ return entry.rootPath
+ }
+
+ if (moduleSpecifier.startsWith(`${entry.alias}/`)) {
+ return path.join(
+ entry.rootPath,
+ moduleSpecifier.slice(entry.alias.length + 1)
+ )
+ }
+
+ if (moduleSpecifier.startsWith(`${entry.canonical}/`)) {
+ return path.join(
+ entry.rootPath,
+ moduleSpecifier.slice(entry.canonical.length + 1)
+ )
+ }
+ }
+
+ return null
+}
+
+function getConfiguredAliasEntries(config: Config) {
+ return [
+ {
+ alias: config.aliases.ui,
+ canonical: "@/components/ui",
+ rootPath: config.resolvedPaths.ui,
+ },
+ {
+ alias: config.aliases.components,
+ canonical: "@/components",
+ rootPath: config.resolvedPaths.components,
+ },
+ {
+ alias: config.aliases.hooks,
+ canonical: "@/hooks",
+ rootPath: config.resolvedPaths.hooks,
+ },
+ {
+ alias: config.aliases.lib,
+ canonical: "@/lib",
+ rootPath: config.resolvedPaths.lib,
+ },
+ {
+ alias: config.aliases.utils,
+ canonical: "@/lib/utils",
+ rootPath: config.resolvedPaths.utils,
+ },
+ ]
+ .filter(
+ (
+ entry
+ ): entry is {
+ alias: string
+ canonical: string
+ rootPath: string
+ } => typeof entry.alias === "string" && typeof entry.rootPath === "string"
+ )
+ .sort(
+ (a, b) =>
+ b.alias.length - a.alias.length ||
+ b.canonical.length - a.canonical.length
+ )
+}
+
+function toRelativeImport(fromFilePath: string, targetFilePath: string) {
+ let rel = path.relative(path.dirname(fromFilePath), targetFilePath)
+ rel = rel.split(path.sep).join("/")
+
+ const ext = path.posix.extname(rel)
+ const keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
+ let noExt = rel.slice(0, rel.length - ext.length)
+
+ if (noExt.endsWith("/index")) {
+ noExt = noExt.slice(0, -"/index".length)
+ }
+
+ if (!noExt.startsWith(".")) {
+ noExt = `./${noExt}`
+ }
+
+ return `${noExt}${keepExt}`
+}
+
function _isNext16Middleware(
filePath: string,
projectInfo: ProjectInfo | null,
diff --git a/packages/shadcn/src/utils/workspace.ts b/packages/shadcn/src/utils/workspace.ts
new file mode 100644
index 0000000000..04d357d8da
--- /dev/null
+++ b/packages/shadcn/src/utils/workspace.ts
@@ -0,0 +1,251 @@
+import path from "path"
+import { getWorkspacePatterns } from "@/src/utils/get-monorepo-info"
+import { getPackageInfo } from "@/src/utils/get-package-info"
+import {
+ getImportTargetEmitMode,
+ resolveImportEntryMatch,
+ resolveLocalPathTarget,
+ type ImportEmitMode,
+ type ImportResolutionEntry,
+ type ImportResolutionMatch,
+} from "@/src/utils/import-matcher"
+import fg from "fast-glob"
+import fs from "fs-extra"
+
+type WorkspacePackageInfo = {
+ packageName: string
+ packageRoot: string
+}
+
+type WorkspacePackageExportEntry = ImportResolutionEntry
+export type WorkspacePackageExportMatch = ImportResolutionMatch
+
+const workspacePackageCache = new Map<
+ string,
+ Map
+>()
+const workspaceExportEntriesCache = new Map<
+ string,
+ WorkspacePackageExportEntry[]
+>()
+const workspaceRootCache = new Map()
+
+export async function resolveWorkspacePackageExport(
+ importPath: string,
+ cwd: string
+) {
+ const specifier = parsePackageSpecifier(importPath)
+
+ if (!specifier) {
+ return null
+ }
+
+ const workspacePackage = await findWorkspacePackage(
+ cwd,
+ specifier.packageName
+ )
+
+ if (!workspacePackage) {
+ return null
+ }
+
+ return resolveImportEntryMatch(
+ importPath,
+ getWorkspacePackageExportEntries(workspacePackage)
+ )
+}
+
+function getWorkspacePackageExportEntries(
+ workspacePackage: WorkspacePackageInfo
+) {
+ const cacheKey = `${workspacePackage.packageRoot}:${workspacePackage.packageName}`
+ const cachedEntries = workspaceExportEntriesCache.get(cacheKey)
+
+ if (cachedEntries) {
+ return cachedEntries
+ }
+
+ const packageInfo = getPackageInfo(workspacePackage.packageRoot, false)
+ const exportsField = packageInfo?.exports
+
+ if (
+ !exportsField ||
+ typeof exportsField !== "object" ||
+ Array.isArray(exportsField)
+ ) {
+ workspaceExportEntriesCache.set(cacheKey, [])
+ return []
+ }
+
+ const entries: WorkspacePackageExportEntry[] = []
+
+ for (const [key, value] of Object.entries(exportsField)) {
+ if (key !== "." && !key.startsWith("./")) {
+ continue
+ }
+
+ const target = resolveLocalPathTarget(value)
+
+ if (!target) {
+ continue
+ }
+
+ const aliasBase = getAliasBase(workspacePackage.packageName, key)
+
+ entries.push({
+ key: key.includes("*") ? `${aliasBase}/*` : aliasBase,
+ aliasBase,
+ target,
+ emitMode: getImportTargetEmitMode(target),
+ hasWildcard: key.includes("*"),
+ rootDir: workspacePackage.packageRoot,
+ })
+ }
+
+ workspaceExportEntriesCache.set(cacheKey, entries)
+ return entries
+}
+
+async function findWorkspacePackage(cwd: string, packageName: string) {
+ const workspaceRoot = await findWorkspaceRoot(cwd)
+
+ if (!workspaceRoot) {
+ return null
+ }
+
+ const cachedPackages = workspacePackageCache.get(workspaceRoot)
+
+ if (cachedPackages?.has(packageName)) {
+ return cachedPackages.get(packageName) ?? null
+ }
+
+ const workspacePackages = await loadWorkspacePackages(workspaceRoot)
+ workspacePackageCache.set(workspaceRoot, workspacePackages)
+
+ return workspacePackages.get(packageName) ?? null
+}
+
+async function loadWorkspacePackages(root: string) {
+ const patterns = await getWorkspacePatterns(root)
+ const packageMap = new Map()
+
+ if (!patterns.length) {
+ return packageMap
+ }
+
+ const packageJsonPaths = await fg(
+ patterns.map((pattern) =>
+ path.posix.join(pattern.split(path.sep).join("/"), "package.json")
+ ),
+ {
+ cwd: root,
+ ignore: ["**/node_modules/**"],
+ }
+ )
+
+ for (const packageJsonPath of packageJsonPaths) {
+ const packageRoot = path.resolve(root, path.dirname(packageJsonPath))
+ const packageInfo = getPackageInfo(packageRoot, false)
+ const name = packageInfo?.name
+
+ if (!name) {
+ continue
+ }
+
+ packageMap.set(name, {
+ packageName: name,
+ packageRoot,
+ })
+ }
+
+ return packageMap
+}
+
+async function findWorkspaceRoot(cwd: string) {
+ const start = path.resolve(cwd)
+ const cachedRoot = workspaceRootCache.get(start)
+
+ if (cachedRoot !== undefined) {
+ return cachedRoot
+ }
+
+ let current = start
+ const gitRoot = await findGitRoot(start)
+
+ while (true) {
+ const patterns = await getWorkspacePatterns(current)
+
+ if (patterns.length) {
+ workspaceRootCache.set(start, current)
+ return current
+ }
+
+ if (gitRoot && current === gitRoot) {
+ workspaceRootCache.set(start, null)
+ return null
+ }
+
+ const parent = path.dirname(current)
+
+ if (parent === current) {
+ workspaceRootCache.set(start, null)
+ return null
+ }
+
+ current = parent
+ }
+}
+
+async function findGitRoot(cwd: string) {
+ let current = path.resolve(cwd)
+
+ while (true) {
+ if (fs.existsSync(path.resolve(current, ".git"))) {
+ return current
+ }
+
+ const parent = path.dirname(current)
+
+ if (parent === current) {
+ return null
+ }
+
+ current = parent
+ }
+}
+
+function parsePackageSpecifier(importPath: string) {
+ if (
+ importPath.startsWith("#") ||
+ importPath.startsWith(".") ||
+ path.isAbsolute(importPath)
+ ) {
+ return null
+ }
+
+ const segments = importPath.split("/")
+
+ if (importPath.startsWith("@")) {
+ if (segments.length < 2) {
+ return null
+ }
+
+ return {
+ packageName: `${segments[0]}/${segments[1]}`,
+ }
+ }
+
+ return {
+ packageName: segments[0],
+ }
+}
+
+function getAliasBase(packageName: string, exportKey: string) {
+ if (exportKey === ".") {
+ return packageName
+ }
+
+ const normalizedKey = exportKey.slice(2).replace(/\/\*$/, "")
+
+ return normalizedKey ? `${packageName}/${normalizedKey}` : packageName
+}
diff --git a/packages/shadcn/test/fixtures/config-imports-extensions/components.json b/packages/shadcn/test/fixtures/config-imports-extensions/components.json
new file mode 100644
index 0000000000..c265cae6fc
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports-extensions/components.json
@@ -0,0 +1,16 @@
+{
+ "style": "new-york",
+ "tailwind": {
+ "css": "src/index.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "rsc": false,
+ "tsx": true,
+ "aliases": {
+ "components": "#components",
+ "ui": "#components/ui",
+ "lib": "#lib",
+ "utils": "#lib/utils"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/config-imports-extensions/package.json b/packages/shadcn/test/fixtures/config-imports-extensions/package.json
new file mode 100644
index 0000000000..3ed5066566
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports-extensions/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "config-imports-extensions",
+ "type": "module",
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#lib/*": "./src/lib/*.ts"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/config-imports-extensions/src/index.css b/packages/shadcn/test/fixtures/config-imports-extensions/src/index.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports-extensions/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/shadcn/test/fixtures/config-imports-extensions/tsconfig.json b/packages/shadcn/test/fixtures/config-imports-extensions/tsconfig.json
new file mode 100644
index 0000000000..f89fad37f9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports-extensions/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/shadcn/test/fixtures/config-imports/components.json b/packages/shadcn/test/fixtures/config-imports/components.json
new file mode 100644
index 0000000000..cb2cfea7de
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports/components.json
@@ -0,0 +1,18 @@
+{
+ "style": "new-york",
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "rsc": true,
+ "tsx": true,
+ "aliases": {
+ "components": "#components",
+ "ui": "#components/ui",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#utils"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/config-imports/package.json b/packages/shadcn/test/fixtures/config-imports/package.json
new file mode 100644
index 0000000000..0fd4121893
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "config-imports",
+ "type": "module",
+ "imports": {
+ "#components/*": "./src/components/*",
+ "#components/ui/*": "./src/components/ui/*",
+ "#lib/*": "./src/lib/*",
+ "#hooks": "./src/hooks/index.ts",
+ "#utils": "./src/lib/utils.ts"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/config-imports/tsconfig.json b/packages/shadcn/test/fixtures/config-imports/tsconfig.json
new file mode 100644
index 0000000000..f89fad37f9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/config-imports/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/next.config.js b/packages/shadcn/test/fixtures/frameworks/next-app-imports/next.config.js
new file mode 100644
index 0000000000..1d6147825a
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/next.config.js
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {}
+
+export default nextConfig
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/package.json b/packages/shadcn/test/fixtures/frameworks/next-app-imports/package.json
new file mode 100644
index 0000000000..16b4d185a9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "next-app-imports",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "tailwindcss": "^3.0.0"
+ },
+ "imports": {
+ "#components/*": "./src/components/*",
+ "#components/ui/*": "./src/components/ui/*",
+ "#lib/*": "./src/lib/*",
+ "#hooks": "./src/hooks/index.ts",
+ "#utils": "./src/lib/utils.ts"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/layout.tsx b/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/layout.tsx
new file mode 100644
index 0000000000..dbce4ea8e3
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/layout.tsx
@@ -0,0 +1,11 @@
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/page.tsx b/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/page.tsx
new file mode 100644
index 0000000000..6ee683e940
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return Hello
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/styles.css b/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/styles.css
new file mode 100644
index 0000000000..b5c61c9567
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/styles.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/tailwind.config.ts b/packages/shadcn/test/fixtures/frameworks/next-app-imports/tailwind.config.ts
new file mode 100644
index 0000000000..b1c6ea436a
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/tailwind.config.ts
@@ -0,0 +1 @@
+export default {}
diff --git a/packages/shadcn/test/fixtures/frameworks/next-app-imports/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/next-app-imports/tsconfig.json
new file mode 100644
index 0000000000..bf6b94eeaf
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/next-app-imports/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "resolvePackageJsonImports": true
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-app-imports/package.json b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/package.json
new file mode 100644
index 0000000000..24fad9f844
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "vite-app-imports",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "dependencies": {
+ "tailwindcss": "^4.1.11"
+ },
+ "imports": {
+ "#custom/*": "./src/*"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-app-imports/src/index.css b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/src/index.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-app-imports/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/tsconfig.json
new file mode 100644
index 0000000000..fe2a9ef136
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-app-imports/vite.config.ts b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/vite.config.ts
new file mode 100644
index 0000000000..15652d9d07
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-app-imports/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from "vite"
+
+export default defineConfig({})
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/components.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/components.json
new file mode 100644
index 0000000000..e664fbf6f2
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/components.json
@@ -0,0 +1,18 @@
+{
+ "style": "new-york",
+ "tailwind": {
+ "config": "",
+ "css": "../../packages/ui/src/styles/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "rsc": false,
+ "tsx": true,
+ "aliases": {
+ "components": "#components",
+ "ui": "@workspace/ui/components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "@workspace/ui/lib/utils"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/package.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/package.json
new file mode 100644
index 0000000000..f86ed0e5d0
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "web",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#*": "./src/*"
+ },
+ "dependencies": {
+ "@workspace/ui": "workspace:*",
+ "tailwindcss": "^4.2.1"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/tsconfig.json
new file mode 100644
index 0000000000..f89fad37f9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/package.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/package.json
new file mode 100644
index 0000000000..0b30673cfd
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "vite-monorepo-imports",
+ "private": true,
+ "workspaces": ["apps/*", "packages/*"]
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/components.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/components.json
new file mode 100644
index 0000000000..70cd00c27b
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/components.json
@@ -0,0 +1,18 @@
+{
+ "style": "new-york",
+ "tailwind": {
+ "config": "",
+ "css": "src/styles/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "rsc": false,
+ "tsx": true,
+ "aliases": {
+ "components": "#components",
+ "ui": "#components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#lib/utils"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/package.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/package.json
new file mode 100644
index 0000000000..1d00279428
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@workspace/ui",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#*": "./src/*"
+ },
+ "exports": {
+ "./globals.css": "./src/styles/globals.css",
+ "./components/*": "./src/components/*.tsx",
+ "./lib/*": "./src/lib/*.ts",
+ "./hooks/*": "./src/hooks/*.ts"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts
new file mode 100644
index 0000000000..efa77d449b
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts
@@ -0,0 +1,3 @@
+export function cn(...inputs: Array) {
+ return inputs.filter(Boolean).join(" ")
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/tsconfig.json
new file mode 100644
index 0000000000..f89fad37f9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/package.json b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/package.json
new file mode 100644
index 0000000000..47477d4353
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "vite-partial-imports",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "imports": {
+ "#components/*": "./src/components/*",
+ "#lib/*": "./src/lib/*"
+ },
+ "dependencies": {
+ "tailwindcss": "^4.2.1"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/src/index.css b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/src/index.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.app.json b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.app.json
new file mode 100644
index 0000000000..b61fbfbd0b
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.app.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "baseUrl": ".",
+ "paths": {
+ "#components/*": ["./src/components/*"],
+ "#lib/*": ["./src/lib/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.json
new file mode 100644
index 0000000000..82a8007eb9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }]
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/vite.config.ts b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/vite.config.ts
new file mode 100644
index 0000000000..15652d9d07
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-partial-imports/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from "vite"
+
+export default defineConfig({})
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-root-imports/package.json b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/package.json
new file mode 100644
index 0000000000..f341ad383e
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "vite-root-imports",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "dependencies": {
+ "tailwindcss": "^4.1.11"
+ },
+ "imports": {
+ "#*": "./src/*"
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-root-imports/src/index.css b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/src/index.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/src/index.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-root-imports/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/tsconfig.json
new file mode 100644
index 0000000000..fe2a9ef136
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-root-imports/vite.config.ts b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/vite.config.ts
new file mode 100644
index 0000000000..15652d9d07
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-root-imports/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from "vite"
+
+export default defineConfig({})
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.app.json b/packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.app.json
new file mode 100644
index 0000000000..fa5b662032
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.app.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler"
+ },
+ "include": ["src"]
+}
diff --git a/packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.json b/packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.json
new file mode 100644
index 0000000000..e50e3bbaf9
--- /dev/null
+++ b/packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/packages/shadcn/test/fixtures/with-package-imports/package.json b/packages/shadcn/test/fixtures/with-package-imports/package.json
new file mode 100644
index 0000000000..6113fc90bd
--- /dev/null
+++ b/packages/shadcn/test/fixtures/with-package-imports/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "with-package-imports",
+ "type": "module",
+ "imports": {
+ "#components/*": "./src/components/*",
+ "#components-ext/*": "./src/components/*.tsx",
+ "#hooks": "./src/hooks/index.ts",
+ "#utils": "./src/lib/utils.ts",
+ "#outside/*": "../outside/*",
+ "#dep": {
+ "node": "dep-node-native",
+ "default": "./dep-polyfill.js"
+ }
+ }
+}
diff --git a/packages/shadcn/test/utils/__snapshots__/transform-import.test.ts.snap b/packages/shadcn/test/utils/__snapshots__/transform-import.test.ts.snap
index 28e5c9a1a9..93bdda49a3 100644
--- a/packages/shadcn/test/utils/__snapshots__/transform-import.test.ts.snap
+++ b/packages/shadcn/test/utils/__snapshots__/transform-import.test.ts.snap
@@ -46,7 +46,7 @@ async function loadMultiple() {
exports[`transform dynamic imports with cn utility 2`] = `
"async function loadWorkspaceCn() {
- const { cn } = await import("@workspace/lib/utils")
+ const { cn } = await import("@workspace/ui/lib/utils")
return cn
}
"
diff --git a/packages/shadcn/test/utils/add-components.test.ts b/packages/shadcn/test/utils/add-components.test.ts
index 1e73ee8a1a..53b7914795 100644
--- a/packages/shadcn/test/utils/add-components.test.ts
+++ b/packages/shadcn/test/utils/add-components.test.ts
@@ -1,6 +1,14 @@
+import os from "os"
+import path from "path"
+import fs from "fs-extra"
import { afterEach, describe, expect, test, vi } from "vitest"
+import { resolveRegistryTree } from "../../src/registry/resolver"
+import { addComponents } from "../../src/utils/add-components"
import type { Config } from "../../src/utils/get-config"
+import { findPackageRoot, getWorkspaceConfig } from "../../src/utils/get-config"
+import { updateFiles } from "../../src/utils/updaters/update-files"
+import { updateFonts } from "../../src/utils/updaters/update-fonts"
// Mock all external dependencies.
vi.mock("../../src/registry/resolver", () => ({
@@ -18,13 +26,20 @@ vi.mock("../../src/utils/get-config", async () => {
}
})
-vi.mock("../../src/utils/updaters/update-files", () => ({
- updateFiles: vi.fn().mockResolvedValue({
- filesCreated: [],
- filesUpdated: [],
- filesSkipped: [],
- }),
-}))
+vi.mock("../../src/utils/updaters/update-files", async () => {
+ const actual = (await vi.importActual(
+ "../../src/utils/updaters/update-files"
+ )) as typeof import("../../src/utils/updaters/update-files")
+
+ return {
+ ...actual,
+ updateFiles: vi.fn().mockResolvedValue({
+ filesCreated: [],
+ filesUpdated: [],
+ filesSkipped: [],
+ }),
+ }
+})
vi.mock("../../src/utils/updaters/update-dependencies", () => ({
updateDependencies: vi.fn().mockResolvedValue(undefined),
@@ -47,9 +62,16 @@ vi.mock("../../src/utils/updaters/update-css", () => ({
updateCss: vi.fn().mockResolvedValue(undefined),
}))
-vi.mock("../../src/utils/get-project-info", () => ({
- getProjectTailwindVersionFromConfig: vi.fn().mockResolvedValue("4"),
-}))
+vi.mock("../../src/utils/get-project-info", async () => {
+ const actual = (await vi.importActual(
+ "../../src/utils/get-project-info"
+ )) as typeof import("../../src/utils/get-project-info")
+
+ return {
+ ...actual,
+ getProjectTailwindVersionFromConfig: vi.fn().mockResolvedValue("4"),
+ }
+})
vi.mock("../../src/utils/spinner", () => ({
spinner: vi.fn().mockReturnValue({
@@ -69,17 +91,13 @@ vi.mock("../../src/utils/logger", () => ({
},
}))
-import { addComponents } from "../../src/utils/add-components"
-import { resolveRegistryTree } from "../../src/registry/resolver"
-import {
- findPackageRoot,
- getWorkspaceConfig,
-} from "../../src/utils/get-config"
-import { updateFiles } from "../../src/utils/updaters/update-files"
-import { updateFonts } from "../../src/utils/updaters/update-fonts"
-
afterEach(() => {
vi.clearAllMocks()
+ vi.mocked(updateFiles).mockResolvedValue({
+ filesCreated: [],
+ filesUpdated: [],
+ filesSkipped: [],
+ })
})
function createMockConfig(overrides: Partial = {}): Config {
@@ -114,6 +132,79 @@ function createMockConfig(overrides: Partial = {}): Config {
} as Config
}
+function createPackageImportConfig(
+ cwd: string,
+ overrides: Partial = {}
+): Config {
+ return {
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ config: "",
+ css: "src/index.css",
+ baseColor: "",
+ cssVariables: true,
+ },
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#utils",
+ },
+ resolvedPaths: {
+ cwd,
+ tailwindConfig: "",
+ tailwindCss: path.resolve(cwd, "src/index.css"),
+ components: path.resolve(cwd, "src/components"),
+ ui: path.resolve(cwd, "src/components/ui"),
+ lib: path.resolve(cwd, "src/lib"),
+ hooks: path.resolve(cwd, "src/hooks"),
+ utils: path.resolve(cwd, "src/lib/utils.ts"),
+ },
+ registries: {},
+ ...overrides,
+ } as Config
+}
+
+async function writePackageImportProject(cwd: string) {
+ await fs.ensureDir(path.resolve(cwd, "src"))
+ await fs.writeJson(
+ path.resolve(cwd, "package.json"),
+ {
+ name: path.basename(cwd),
+ type: "module",
+ dependencies: {
+ tailwindcss: "^4.0.0",
+ },
+ imports: {
+ "#components/*": "./src/components/*",
+ "#hooks": "./src/hooks/index.ts",
+ "#utils": "./src/lib/utils.ts",
+ },
+ },
+ { spaces: 2 }
+ )
+ await fs.writeJson(
+ path.resolve(cwd, "tsconfig.json"),
+ {
+ compilerOptions: {
+ module: "esnext",
+ moduleResolution: "bundler",
+ resolvePackageJsonImports: true,
+ },
+ },
+ { spaces: 2 }
+ )
+ await fs.writeFile(
+ path.resolve(cwd, "src/index.css"),
+ '@import "tailwindcss";\n'
+ )
+ await fs.writeFile(path.resolve(cwd, "vite.config.ts"), "export default {}\n")
+}
+
describe("addComponents workspace routing", () => {
test("should route registry:hook files to workspaceConfig.hooks", async () => {
const appConfig = createMockConfig()
@@ -712,6 +803,74 @@ describe("addComponents workspace routing", () => {
)
})
+ test("should rewrite cross-type imports when files target the same workspace package", async () => {
+ const actualUpdateFiles = (await vi.importActual(
+ "../../src/utils/updaters/update-files"
+ )) as typeof import("../../src/utils/updaters/update-files")
+ vi.mocked(updateFiles).mockImplementation(actualUpdateFiles.updateFiles)
+
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-cross-type-"))
+ const appCwd = path.resolve(root, "apps/web")
+ const uiCwd = path.resolve(root, "packages/ui")
+
+ try {
+ await writePackageImportProject(appCwd)
+ await writePackageImportProject(uiCwd)
+
+ const appConfig = createPackageImportConfig(appCwd)
+ const uiConfig = createPackageImportConfig(uiCwd)
+
+ vi.mocked(getWorkspaceConfig).mockResolvedValue({
+ components: appConfig,
+ ui: uiConfig,
+ lib: appConfig,
+ hooks: appConfig,
+ })
+
+ vi.mocked(resolveRegistryTree).mockResolvedValue({
+ name: "example-card",
+ files: [
+ {
+ path: "registry/components/example-card.tsx",
+ type: "registry:component",
+ content: `import { useThing } from "@/hooks/use-thing"
+
+export function ExampleCard() {
+ useThing()
+ return null
+}
+`,
+ },
+ {
+ path: "registry/hooks/use-thing.ts",
+ type: "registry:hook",
+ content: `export function useThing() {
+ return true
+}
+`,
+ },
+ ],
+ dependencies: [],
+ devDependencies: [],
+ })
+
+ await addComponents(["example-card"], appConfig, {
+ overwrite: true,
+ silent: true,
+ })
+
+ const componentContent = await fs.readFile(
+ path.resolve(appCwd, "src/components/example-card.tsx"),
+ "utf-8"
+ )
+
+ expect(componentContent).toContain(`from "../hooks/use-thing"`)
+ expect(componentContent).not.toContain(`from "#hooks/use-thing"`)
+ } finally {
+ await fs.remove(root)
+ }
+ })
+
test("should call updateFonts with app config, not workspace config", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
diff --git a/packages/shadcn/test/utils/dry-run.test.ts b/packages/shadcn/test/utils/dry-run.test.ts
index 2fa693cf69..a01867ea15 100644
--- a/packages/shadcn/test/utils/dry-run.test.ts
+++ b/packages/shadcn/test/utils/dry-run.test.ts
@@ -1,6 +1,9 @@
+import { existsSync, promises as fs } from "fs"
+import path from "path"
import { afterEach, describe, expect, test, vi } from "vitest"
import type { Config } from "../../src/utils/get-config"
+import { getConfig } from "../../src/utils/get-config"
// Mock external dependencies.
vi.mock("../../src/registry/resolver", () => ({
@@ -94,9 +97,22 @@ import {
} from "../../src/utils/dry-run-formatter"
import type { DryRunResult } from "../../src/utils/dry-run"
import { resolveRegistryTree } from "../../src/registry/resolver"
+import { getProjectInfo } from "../../src/utils/get-project-info"
+import { transform } from "../../src/utils/transformers"
+import { transformAsChild } from "../../src/utils/transformers/transform-aschild"
+import { transformCleanup } from "../../src/utils/transformers/transform-cleanup"
+import { transformCssVars as transformCssVarsTransformer } from "../../src/utils/transformers/transform-css-vars"
+import { transformIcons } from "../../src/utils/transformers/transform-icons"
+import { transformImport } from "../../src/utils/transformers/transform-import"
+import { transformMenu } from "../../src/utils/transformers/transform-menu"
+import { transformRsc } from "../../src/utils/transformers/transform-rsc"
+import { transformRtl } from "../../src/utils/transformers/transform-rtl"
+import { transformTwPrefixes } from "../../src/utils/transformers/transform-tw-prefix"
afterEach(() => {
vi.clearAllMocks()
+ vi.mocked(existsSync).mockReturnValue(false)
+ vi.mocked(fs.readFile).mockResolvedValue("" as never)
})
function createMockConfig(overrides: Partial = {}): Config {
@@ -408,6 +424,244 @@ describe("dryRunComponents", () => {
dryRunComponents(["nonexistent"], config)
).rejects.toThrow("Failed to fetch components from registry.")
})
+
+ test("should skip package-import files when final rewritten content matches", async () => {
+ const tempDir = path.join(
+ path.resolve(__dirname, "../fixtures"),
+ "temp-dry-run-package-import-same"
+ )
+ const actualFs = (await vi.importActual("fs")) as typeof import("fs")
+
+ try {
+ vi.mocked(existsSync).mockImplementation(actualFs.existsSync)
+ vi.mocked(fs.readFile).mockImplementation(actualFs.promises.readFile as never)
+ vi.mocked(getProjectInfo).mockResolvedValue({
+ framework: { name: "vite" } as any,
+ isSrcDir: true,
+ isRSC: false,
+ isTsx: true,
+ tailwindConfigFile: null,
+ tailwindCssFile: "src/index.css",
+ tailwindVersion: "v4",
+ frameworkVersion: null,
+ aliasPrefix: "#",
+ })
+
+ await actualFs.promises.rm(tempDir, { recursive: true, force: true })
+ await actualFs.promises.mkdir(path.join(tempDir, "src", "components", "ui"), {
+ recursive: true,
+ })
+ await actualFs.promises.mkdir(path.join(tempDir, "src", "lib"), {
+ recursive: true,
+ })
+
+ await actualFs.promises.writeFile(
+ path.join(tempDir, "package.json"),
+ JSON.stringify(
+ {
+ name: "temp-dry-run-package-import-same",
+ type: "module",
+ imports: {
+ "#components/*": "./src/components/*",
+ "#lib/*": "./src/lib/*",
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await actualFs.promises.writeFile(
+ path.join(tempDir, "tsconfig.json"),
+ JSON.stringify(
+ {
+ files: [],
+ references: [{ path: "./tsconfig.app.json" }],
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await actualFs.promises.writeFile(
+ path.join(tempDir, "tsconfig.app.json"),
+ JSON.stringify(
+ {
+ compilerOptions: {
+ module: "esnext",
+ moduleResolution: "bundler",
+ baseUrl: ".",
+ paths: {
+ "#components/*": ["./src/components/*"],
+ "#lib/*": ["./src/lib/*"],
+ },
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await actualFs.promises.writeFile(
+ path.join(tempDir, "src", "index.css"),
+ '@import "tailwindcss";\n',
+ "utf-8"
+ )
+
+ await actualFs.promises.writeFile(
+ path.join(tempDir, "src", "lib", "utils.ts"),
+ "export function cn(...inputs: unknown[]) {\n return inputs\n}\n",
+ "utf-8"
+ )
+
+ await actualFs.promises.writeFile(
+ path.join(tempDir, "src", "components", "ui", "button.tsx"),
+ `import { cn } from "#lib/utils.ts"
+
+export function Button() {
+ return
+}
+`,
+ "utf-8"
+ )
+
+ const config = createMockConfig({
+ rsc: false,
+ aliases: {
+ components: "#components",
+ utils: "#lib/utils",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: undefined,
+ },
+ resolvedPaths: {
+ cwd: tempDir,
+ tailwindConfig: "",
+ tailwindCss: path.join(tempDir, "src", "index.css"),
+ utils: path.join(tempDir, "src", "lib", "utils.ts"),
+ components: path.join(tempDir, "src", "components"),
+ lib: path.join(tempDir, "src", "lib"),
+ hooks: path.join(tempDir, "src", "hooks"),
+ ui: path.join(tempDir, "src", "components", "ui"),
+ },
+ })
+
+ vi.mocked(resolveRegistryTree).mockResolvedValue({
+ name: "button",
+ files: [
+ {
+ path: "registry/default/ui/button.tsx",
+ type: "registry:ui",
+ content: `import { cn } from "#lib/utils"
+
+export function Button() {
+ return
+}
+`,
+ },
+ ],
+ dependencies: [],
+ devDependencies: [],
+ })
+
+ const result = await dryRunComponents(["button"], config)
+
+ expect(result.files).toHaveLength(1)
+ expect(result.files[0]).toMatchObject({
+ path: "src/components/ui/button.tsx",
+ action: "skip",
+ })
+ expect(result.files[0].content).toContain(`from "#lib/utils.ts"`)
+ } finally {
+ await actualFs.promises.rm(tempDir, { recursive: true, force: true })
+ }
+ })
+
+ test("should rewrite app-local files to workspace utils aliases in monorepo dry-runs", async () => {
+ const actualFs = (await vi.importActual("fs")) as typeof import("fs")
+ const actualTransformModule = (await vi.importActual(
+ "../../src/utils/transformers"
+ )) as typeof import("../../src/utils/transformers")
+ const actualTransformImportModule = (await vi.importActual(
+ "../../src/utils/transformers/transform-import"
+ )) as typeof import("../../src/utils/transformers/transform-import")
+ const cwd = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web"
+ )
+
+ vi.mocked(existsSync).mockImplementation(actualFs.existsSync)
+ vi.mocked(fs.readFile).mockImplementation(actualFs.promises.readFile as never)
+ vi.mocked(getProjectInfo).mockResolvedValue({
+ framework: { name: "vite" } as any,
+ isSrcDir: true,
+ isRSC: false,
+ isTsx: true,
+ tailwindConfigFile: null,
+ tailwindCssFile: "../../packages/ui/src/styles/globals.css",
+ tailwindVersion: "v4",
+ frameworkVersion: null,
+ aliasPrefix: "#",
+ })
+ vi.mocked(transform).mockImplementationOnce(actualTransformModule.transform)
+ vi.mocked(transformImport).mockImplementationOnce(
+ actualTransformImportModule.transformImport
+ )
+
+ for (const transformer of [
+ transformRsc,
+ transformCssVarsTransformer,
+ transformTwPrefixes,
+ transformIcons,
+ transformMenu,
+ transformAsChild,
+ transformRtl,
+ transformCleanup,
+ ]) {
+ vi.mocked(transformer).mockImplementationOnce(async ({ sourceFile }) => {
+ return sourceFile
+ })
+ }
+
+ const config = await getConfig(cwd)
+ if (!config) {
+ throw new Error("Failed to get monorepo app config")
+ }
+
+ vi.mocked(resolveRegistryTree).mockResolvedValue({
+ name: "login-03",
+ files: [
+ {
+ path: "registry/components/login-form.tsx",
+ type: "registry:component",
+ content: `import { cn } from "@/lib/utils"
+
+export function LoginForm() {
+ return {cn("login")}
+}
+`,
+ },
+ ],
+ dependencies: [],
+ devDependencies: [],
+ })
+
+ const result = await dryRunComponents(["login-03"], config)
+
+ expect(result.files).toHaveLength(1)
+ expect(result.files[0]).toMatchObject({
+ path: "src/components/login-form.tsx",
+ action: "create",
+ type: "registry:component",
+ })
+ expect(result.files[0].content).toContain(
+ `from "@workspace/ui/lib/utils"`
+ )
+ expect(result.files[0].content).not.toContain(`from "#lib/utils"`)
+ })
})
describe("formatDryRunResult", () => {
diff --git a/packages/shadcn/test/utils/get-config.test.ts b/packages/shadcn/test/utils/get-config.test.ts
index a8f09c54c6..ead7bdfa0b 100644
--- a/packages/shadcn/test/utils/get-config.test.ts
+++ b/packages/shadcn/test/utils/get-config.test.ts
@@ -1,4 +1,6 @@
+import os from "os"
import path from "path"
+import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import {
@@ -6,7 +8,9 @@ import {
getBase,
getConfig,
getRawConfig,
+ getWorkspaceConfig,
} from "../../src/utils/get-config"
+import { getProjectConfig } from "../../src/utils/get-project-info"
test("get raw config", async () => {
expect(
@@ -36,6 +40,164 @@ test("get raw config", async () => {
).rejects.toThrowError()
})
+test("get project config from package imports", async () => {
+ const cwd = path.resolve(__dirname, "../fixtures/frameworks/next-app-imports")
+
+ expect(await getProjectConfig(cwd)).toEqual({
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: true,
+ tsx: true,
+ tailwind: {
+ config: "tailwind.config.ts",
+ baseColor: "zinc",
+ css: "src/app/styles.css",
+ cssVariables: true,
+ prefix: "",
+ },
+ iconLibrary: "lucide",
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#utils",
+ },
+ resolvedPaths: {
+ cwd,
+ tailwindConfig: path.resolve(cwd, "tailwind.config.ts"),
+ tailwindCss: path.resolve(cwd, "src/app/styles.css"),
+ components: path.resolve(cwd, "src/components"),
+ ui: path.resolve(cwd, "src/components/ui"),
+ lib: path.resolve(cwd, "src/lib"),
+ hooks: path.resolve(cwd, "src/hooks"),
+ utils: path.resolve(cwd, "src/lib/utils.ts"),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+})
+
+test("get project config from generic package import prefix", async () => {
+ const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-app-imports")
+
+ expect(await getProjectConfig(cwd)).toEqual({
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ config: "",
+ baseColor: "zinc",
+ css: "src/index.css",
+ cssVariables: true,
+ prefix: "",
+ },
+ iconLibrary: "lucide",
+ aliases: {
+ components: "#custom/components",
+ ui: "#custom/components/ui",
+ lib: "#custom/lib",
+ hooks: "#custom/hooks",
+ utils: "#custom/lib/utils",
+ },
+ resolvedPaths: {
+ cwd,
+ tailwindConfig: "",
+ tailwindCss: path.resolve(cwd, "src/index.css"),
+ components: path.resolve(cwd, "src/components"),
+ ui: path.resolve(cwd, "src/components/ui"),
+ lib: path.resolve(cwd, "src/lib"),
+ hooks: path.resolve(cwd, "src/hooks"),
+ utils: path.resolve(cwd, "src/lib/utils"),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+})
+
+test("get project config from root package imports", async () => {
+ const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-root-imports")
+
+ expect(await getProjectConfig(cwd)).toEqual({
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ config: "",
+ baseColor: "zinc",
+ css: "src/index.css",
+ cssVariables: true,
+ prefix: "",
+ },
+ iconLibrary: "lucide",
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#lib/utils",
+ },
+ resolvedPaths: {
+ cwd,
+ tailwindConfig: "",
+ tailwindCss: path.resolve(cwd, "src/index.css"),
+ components: path.resolve(cwd, "src/components"),
+ ui: path.resolve(cwd, "src/components/ui"),
+ lib: path.resolve(cwd, "src/lib"),
+ hooks: path.resolve(cwd, "src/hooks"),
+ utils: path.resolve(cwd, "src/lib/utils"),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+})
+
+test("get project config from partial package imports", async () => {
+ const cwd = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-partial-imports"
+ )
+
+ expect(await getProjectConfig(cwd)).toEqual({
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ config: "",
+ baseColor: "zinc",
+ css: "src/index.css",
+ cssVariables: true,
+ prefix: "",
+ },
+ iconLibrary: "lucide",
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ utils: "#lib/utils",
+ },
+ resolvedPaths: {
+ cwd,
+ tailwindConfig: "",
+ tailwindCss: path.resolve(cwd, "src/index.css"),
+ components: path.resolve(cwd, "src/components"),
+ ui: path.resolve(cwd, "src/components/ui"),
+ lib: path.resolve(cwd, "src/lib"),
+ hooks: path.resolve(cwd, "src/hooks"),
+ utils: path.resolve(cwd, "src/lib/utils"),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+})
+
test("get config", async () => {
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-none"))
@@ -196,6 +358,282 @@ test("get config", async () => {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
+
+ expect(
+ await getConfig(path.resolve(__dirname, "../fixtures/config-imports"))
+ ).toEqual({
+ style: "new-york",
+ rsc: true,
+ tsx: true,
+ tailwind: {
+ config: "tailwind.config.ts",
+ baseColor: "zinc",
+ css: "src/app/globals.css",
+ cssVariables: true,
+ },
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "#utils",
+ },
+ iconLibrary: "radix",
+ resolvedPaths: {
+ cwd: path.resolve(__dirname, "../fixtures/config-imports"),
+ tailwindConfig: path.resolve(
+ __dirname,
+ "../fixtures/config-imports",
+ "tailwind.config.ts"
+ ),
+ tailwindCss: path.resolve(
+ __dirname,
+ "../fixtures/config-imports",
+ "src/app/globals.css"
+ ),
+ components: path.resolve(
+ __dirname,
+ "../fixtures/config-imports",
+ "src/components"
+ ),
+ ui: path.resolve(
+ __dirname,
+ "../fixtures/config-imports",
+ "src/components/ui"
+ ),
+ lib: path.resolve(__dirname, "../fixtures/config-imports", "src/lib"),
+ hooks: path.resolve(__dirname, "../fixtures/config-imports", "src/hooks"),
+ utils: path.resolve(
+ __dirname,
+ "../fixtures/config-imports",
+ "src/lib/utils.ts"
+ ),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+
+ expect(
+ await getConfig(
+ path.resolve(__dirname, "../fixtures/config-imports-extensions")
+ )
+ ).toEqual({
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ css: "src/index.css",
+ baseColor: "zinc",
+ cssVariables: true,
+ },
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ utils: "#lib/utils",
+ },
+ iconLibrary: "radix",
+ resolvedPaths: {
+ cwd: path.resolve(__dirname, "../fixtures/config-imports-extensions"),
+ tailwindConfig: "",
+ tailwindCss: path.resolve(
+ __dirname,
+ "../fixtures/config-imports-extensions",
+ "src/index.css"
+ ),
+ components: path.resolve(
+ __dirname,
+ "../fixtures/config-imports-extensions",
+ "src/components"
+ ),
+ ui: path.resolve(
+ __dirname,
+ "../fixtures/config-imports-extensions",
+ "src/components/ui"
+ ),
+ lib: path.resolve(
+ __dirname,
+ "../fixtures/config-imports-extensions",
+ "src/lib"
+ ),
+ hooks: path.resolve(
+ __dirname,
+ "../fixtures/config-imports-extensions",
+ "src/hooks"
+ ),
+ utils: path.resolve(
+ __dirname,
+ "../fixtures/config-imports-extensions",
+ "src/lib/utils.ts"
+ ),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+
+ expect(
+ await getConfig(
+ path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web"
+ )
+ )
+ ).toEqual({
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ config: "",
+ css: "../../packages/ui/src/styles/globals.css",
+ baseColor: "zinc",
+ cssVariables: true,
+ },
+ aliases: {
+ components: "#components",
+ ui: "@workspace/ui/components",
+ lib: "#lib",
+ hooks: "#hooks",
+ utils: "@workspace/ui/lib/utils",
+ },
+ iconLibrary: "radix",
+ resolvedPaths: {
+ cwd: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web"
+ ),
+ tailwindConfig: "",
+ tailwindCss: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css"
+ ),
+ components: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web/src/components"
+ ),
+ ui: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/components"
+ ),
+ lib: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web/src/lib"
+ ),
+ hooks: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web/src/hooks"
+ ),
+ utils: path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts"
+ ),
+ },
+ registries: {
+ "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
+ },
+ })
+})
+
+test("get workspace config resolves cross-package aliases without tsconfig paths", async () => {
+ const appCwd = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/apps/web"
+ )
+ const uiCwd = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports/packages/ui"
+ )
+
+ const config = await getConfig(appCwd)
+ if (!config) {
+ throw new Error("Failed to load monorepo app config")
+ }
+
+ expect(await getWorkspaceConfig(config)).toMatchObject({
+ components: {
+ resolvedPaths: {
+ cwd: appCwd,
+ },
+ },
+ ui: {
+ resolvedPaths: {
+ cwd: uiCwd,
+ },
+ },
+ lib: {
+ resolvedPaths: {
+ cwd: appCwd,
+ },
+ },
+ hooks: {
+ resolvedPaths: {
+ cwd: appCwd,
+ },
+ },
+ })
+})
+
+test("get workspace config shows an actionable error when a workspace package is missing imports", async () => {
+ const fixtureRoot = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports"
+ )
+ const tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), "shadcn-workspace-config-")
+ )
+
+ try {
+ await fs.copy(fixtureRoot, tempDir)
+
+ const uiPackageJsonPath = path.resolve(tempDir, "packages/ui/package.json")
+ const uiPackageJson = await fs.readJson(uiPackageJsonPath)
+ delete uiPackageJson.imports
+ await fs.writeJson(uiPackageJsonPath, uiPackageJson, { spaces: 2 })
+
+ const config = await getConfig(path.resolve(tempDir, "apps/web"))
+ if (!config) {
+ throw new Error("Failed to load broken monorepo app config")
+ }
+
+ await expect(getWorkspaceConfig(config)).rejects.toThrowError(
+ new RegExp(
+ "Could not resolve the following aliases.*packages/ui.*components, ui, lib, hooks, utils",
+ "s"
+ )
+ )
+ } finally {
+ await fs.remove(tempDir)
+ }
+})
+
+test("get workspace config shows an actionable error when a workspace package is missing components.json", async () => {
+ const fixtureRoot = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports"
+ )
+ const tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), "shadcn-workspace-config-")
+ )
+
+ try {
+ await fs.copy(fixtureRoot, tempDir)
+ await fs.remove(path.resolve(tempDir, "packages/ui/components.json"))
+
+ const config = await getConfig(path.resolve(tempDir, "apps/web"))
+ if (!config) {
+ throw new Error("Failed to load broken monorepo app config")
+ }
+
+ await expect(getWorkspaceConfig(config)).rejects.toThrowError(
+ new RegExp(
+ "Could not load the workspace config.*packages/ui.*components.json.*path aliases or package imports",
+ "s"
+ )
+ )
+ } finally {
+ await fs.remove(tempDir)
+ }
})
describe("getBase", () => {
diff --git a/packages/shadcn/test/utils/get-project-info.test.ts b/packages/shadcn/test/utils/get-project-info.test.ts
index e6d68907e2..7ee54fd4ff 100644
--- a/packages/shadcn/test/utils/get-project-info.test.ts
+++ b/packages/shadcn/test/utils/get-project-info.test.ts
@@ -48,6 +48,62 @@ describe("get project info", async () => {
aliasPrefix: "#",
},
},
+ {
+ name: "next-app-imports",
+ type: {
+ framework: FRAMEWORKS["next-app"],
+ isSrcDir: true,
+ isRSC: true,
+ isTsx: true,
+ tailwindConfigFile: "tailwind.config.ts",
+ tailwindCssFile: "src/app/styles.css",
+ tailwindVersion: "v3",
+ frameworkVersion: null,
+ aliasPrefix: "#",
+ },
+ },
+ {
+ name: "vite-app-imports",
+ type: {
+ framework: FRAMEWORKS["vite"],
+ isSrcDir: true,
+ isRSC: false,
+ isTsx: true,
+ tailwindConfigFile: null,
+ tailwindCssFile: "src/index.css",
+ tailwindVersion: "v4",
+ frameworkVersion: null,
+ aliasPrefix: "#custom",
+ },
+ },
+ {
+ name: "vite-root-imports",
+ type: {
+ framework: FRAMEWORKS["vite"],
+ isSrcDir: true,
+ isRSC: false,
+ isTsx: true,
+ tailwindConfigFile: null,
+ tailwindCssFile: "src/index.css",
+ tailwindVersion: "v4",
+ frameworkVersion: null,
+ aliasPrefix: "#",
+ },
+ },
+ {
+ name: "vite-partial-imports",
+ type: {
+ framework: FRAMEWORKS["vite"],
+ isSrcDir: true,
+ isRSC: false,
+ isTsx: true,
+ tailwindConfigFile: null,
+ tailwindCssFile: "src/index.css",
+ tailwindVersion: "v4",
+ frameworkVersion: null,
+ aliasPrefix: "#",
+ },
+ },
{
name: "next-pages",
type: {
diff --git a/packages/shadcn/test/utils/get-ts-config-alias-prefix.test.ts b/packages/shadcn/test/utils/get-ts-config-alias-prefix.test.ts
index 17acb7a5c7..b3fcac9f81 100644
--- a/packages/shadcn/test/utils/get-ts-config-alias-prefix.test.ts
+++ b/packages/shadcn/test/utils/get-ts-config-alias-prefix.test.ts
@@ -1,4 +1,6 @@
+import { tmpdir } from "os"
import path from "path"
+import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import { getTsConfigAliasPrefix } from "../../src/utils/get-project-info"
@@ -29,6 +31,14 @@ describe("get ts config alias prefix", async () => {
name: "next-app-custom-alias",
prefix: "@custom-alias",
},
+ {
+ name: "vite-partial-imports",
+ prefix: "#components",
+ },
+ {
+ name: "vite-root-paths",
+ prefix: "@",
+ },
])(`getTsConfigAliasPrefix($name) -> $prefix`, async ({ name, prefix }) => {
expect(
await getTsConfigAliasPrefix(
@@ -36,4 +46,29 @@ describe("get ts config alias prefix", async () => {
)
).toBe(prefix)
})
+
+ test("parses JSONC tsconfig files with trailing commas", async () => {
+ const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-jsonc-tsconfig-"))
+
+ try {
+ await fs.writeFile(
+ path.join(cwd, "tsconfig.json"),
+ `{
+ // This mirrors the JSONC shape emitted by common TS templates.
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"], // trailing comments are valid JSONC.
+ },
+ },
+ }
+ `,
+ "utf8"
+ )
+
+ expect(await getTsConfigAliasPrefix(cwd)).toBe("@")
+ } finally {
+ await fs.remove(cwd)
+ }
+ })
})
diff --git a/packages/shadcn/test/utils/resolve-import.test.ts b/packages/shadcn/test/utils/resolve-import.test.ts
index dce1dc9e96..8d6f679d1d 100644
--- a/packages/shadcn/test/utils/resolve-import.test.ts
+++ b/packages/shadcn/test/utils/resolve-import.test.ts
@@ -1,8 +1,13 @@
import path from "path"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
-import { expect, test } from "vitest"
+import { describe, expect, test } from "vitest"
-import { resolveImport } from "../../src/utils/resolve-import"
+import { resolvePackageImport } from "../../src/utils/package-imports"
+import {
+ isLocalAliasImport,
+ resolveImport,
+ resolveImportWithMetadata,
+} from "../../src/utils/resolve-import"
test("resolve import", async () => {
expect(
@@ -79,3 +84,154 @@ test("resolve import without base url", async () => {
path.resolve(cwd, "foo/bar")
)
})
+
+describe("resolve package imports", () => {
+ const cwd = path.resolve(__dirname, "../fixtures/with-package-imports")
+ const config = {
+ absoluteBaseUrl: cwd,
+ paths: {},
+ cwd,
+ }
+
+ test("resolves wildcard imports that preserve extensions", async () => {
+ const result = await resolveImportWithMetadata(
+ "#components/button.tsx",
+ config
+ )
+
+ expect(result).toEqual({
+ path: path.resolve(cwd, "src/components/button.tsx"),
+ source: "package_imports",
+ matchedAlias: "#components/*",
+ matchedTarget: "./src/components/*",
+ emitMode: "preserve_extension",
+ })
+ })
+
+ test("resolves wildcard imports that strip extensions", async () => {
+ const result = await resolveImportWithMetadata(
+ "#components-ext/button",
+ config
+ )
+
+ expect(result).toEqual({
+ path: path.resolve(cwd, "src/components/button.tsx"),
+ source: "package_imports",
+ matchedAlias: "#components-ext/*",
+ matchedTarget: "./src/components/*.tsx",
+ emitMode: "strip_extension",
+ })
+ })
+
+ test("resolves the root alias for wildcard package imports", async () => {
+ expect(await resolveImport("#components", config)).toEqual(
+ path.resolve(cwd, "src/components")
+ )
+ })
+
+ test("resolves exact imports and prefers local conditional targets", async () => {
+ expect(await resolveImport("#hooks", config)).toEqual(
+ path.resolve(cwd, "src/hooks/index.ts")
+ )
+
+ expect(await resolveImport("#dep", config)).toEqual(
+ path.resolve(cwd, "dep-polyfill.js")
+ )
+ })
+
+ test("ignores package import targets outside the package", async () => {
+ expect(resolvePackageImport("#outside/file", cwd)).toBeNull()
+ })
+
+ test("falls back to tsconfig paths when package imports do not match", async () => {
+ expect(
+ await resolveImportWithMetadata("#/components/ui", {
+ absoluteBaseUrl: "/Users/shadcn/Projects/foobar",
+ cwd,
+ paths: {
+ "#/*": ["./src/*"],
+ },
+ })
+ ).toEqual({
+ path: "/Users/shadcn/Projects/foobar/src/components/ui",
+ source: "tsconfig_paths",
+ matchedAlias: "#/*",
+ matchedTarget: "./src/components/ui",
+ emitMode: "strip_extension",
+ })
+ })
+
+ test("resolves @/ via tsconfig paths and #/ via package imports in a mixed project", async () => {
+ const tsconfigPath = await resolveImportWithMetadata(
+ "@/components/button",
+ {
+ absoluteBaseUrl: cwd,
+ cwd,
+ paths: {
+ "@/*": ["./src/*"],
+ },
+ }
+ )
+ expect(tsconfigPath?.source).toBe("tsconfig_paths")
+ expect(tsconfigPath?.path).toBe(path.resolve(cwd, "src/components/button"))
+
+ const packageImportPath = await resolveImportWithMetadata(
+ "#components/button.tsx",
+ {
+ absoluteBaseUrl: cwd,
+ cwd,
+ paths: {
+ "@/*": ["./src/*"],
+ },
+ }
+ )
+ expect(packageImportPath?.source).toBe("package_imports")
+ expect(packageImportPath?.path).toBe(
+ path.resolve(cwd, "src/components/button.tsx")
+ )
+ })
+})
+
+describe("resolve workspace package exports", () => {
+ const root = path.resolve(
+ __dirname,
+ "../fixtures/frameworks/vite-monorepo-imports"
+ )
+ const cwd = path.resolve(root, "apps/web")
+ const config = {
+ absoluteBaseUrl: cwd,
+ paths: {},
+ cwd,
+ }
+
+ test("resolves workspace package wildcard exports for file imports", async () => {
+ const result = await resolveImportWithMetadata(
+ "@workspace/ui/components/button",
+ config
+ )
+
+ expect(result).toEqual({
+ path: path.resolve(root, "packages/ui/src/components/button.tsx"),
+ source: "workspace_package_exports",
+ matchedAlias: "@workspace/ui/components/*",
+ matchedTarget: "./src/components/*.tsx",
+ emitMode: "strip_extension",
+ })
+ })
+
+ test("resolves bare alias roots from workspace package wildcard exports", async () => {
+ expect(await resolveImport("@workspace/ui/components", config)).toEqual(
+ path.resolve(root, "packages/ui/src/components")
+ )
+
+ expect(await resolveImport("@workspace/ui/lib/utils", config)).toEqual(
+ path.resolve(root, "packages/ui/src/lib/utils.ts")
+ )
+ })
+
+ test("does not treat workspace package exports as local alias imports", () => {
+ expect(isLocalAliasImport("@workspace/ui/components/button", "#")).toBe(
+ false
+ )
+ })
+})
diff --git a/packages/shadcn/test/utils/transform-import.test.ts b/packages/shadcn/test/utils/transform-import.test.ts
index 23efb64b33..dbc92da999 100644
--- a/packages/shadcn/test/utils/transform-import.test.ts
+++ b/packages/shadcn/test/utils/transform-import.test.ts
@@ -2,8 +2,7 @@ import { expect, test } from "vitest"
import { transform } from "../../src/utils/transformers"
-
-test('transform nested workspace folder for utils, website/src/utils', async () => {
+test("transform nested workspace folder for utils, website/src/utils", async () => {
expect(
await transform({
filename: "test.ts",
@@ -31,9 +30,50 @@ test('transform nested workspace folder for utils, website/src/utils', async ()
import { cn } from "website/src/utils"
"
`)
-
})
+test.each([
+ {
+ name: "bare aliases",
+ aliases: {
+ components: "components",
+ ui: "components/ui",
+ lib: "lib",
+ utils: "lib/utils",
+ },
+ buttonImport: `import { Button } from "components/ui/button"`,
+ utilsImport: `import { cn } from "lib/utils"`,
+ },
+ {
+ name: "path-like aliases",
+ aliases: {
+ components: "website/src/components",
+ ui: "website/src/components/ui",
+ lib: "website/src/lib",
+ utils: "website/src/lib/utils",
+ },
+ buttonImport: `import { Button } from "website/src/components/ui/button"`,
+ utilsImport: `import { cn } from "website/src/lib/utils"`,
+ },
+])(
+ "transform import with non-sigil aliases: $name",
+ async ({ aliases, buttonImport, utilsImport }) => {
+ const result = await transform({
+ filename: "test.ts",
+ raw: `import { Button } from "@/registry/new-york/ui/button"
+import { cn } from "@/lib/utils"
+`,
+ config: {
+ tsx: true,
+ aliases,
+ },
+ })
+
+ expect(result).toContain(buttonImport)
+ expect(result).toContain(utilsImport)
+ }
+)
+
test("transform import", async () => {
expect(
await transform({
@@ -176,6 +216,76 @@ import { Foo } from "bar"
).toMatchSnapshot()
})
+test("transform import with configured package-import aliases", async () => {
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import { Button } from "#app/components/ui/button"
+import { cn } from "#app/lib/utils"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#app/components",
+ ui: "#app/components/ui",
+ lib: "#app/lib",
+ utils: "#app/lib/utils",
+ },
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ "import { Button } from "#app/components/ui/button"
+ import { cn } from "#app/lib/utils"
+ "
+ `)
+})
+
+test("transform import keeps exact #utils aliases", async () => {
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import { cn } from "@/lib/utils"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ utils: "#utils",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ "import { cn } from "#utils"
+ "
+ `)
+})
+
+test("transform import keeps #lib/utils aliases", async () => {
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import { cn } from "@/lib/utils"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ utils: "#lib/utils",
+ ui: "#components/ui",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ "import { cn } from "#lib/utils"
+ "
+ `)
+})
+
test("transform import for monorepo", async () => {
expect(
await transform({
@@ -228,6 +338,160 @@ import { Foo } from "bar"
).toMatchSnapshot()
})
+test("transform package import aliases and #registry placeholders", async () => {
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import { Button } from "#registry/new-york/ui/button"
+import { Card } from "#/registry/new-york/ui/card"
+import * as RegistryRoot from "#registry"
+import * as RegistryRootCompat from "#/registry"
+import { cn } from "#utils"
+import { helper } from "#lib/helpers"
+import { useThing } from "#hooks/use-thing"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ utils: "#utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toContain(`import { Button } from "#components/ui/button"`)
+
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import { Button } from "#registry/new-york/ui/button"
+import { Card } from "#/registry/new-york/ui/card"
+import * as RegistryRoot from "#registry"
+import * as RegistryRootCompat from "#/registry"
+import { cn } from "#utils"
+import { helper } from "#lib/helpers"
+import { useThing } from "#hooks/use-thing"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ utils: "#utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toContain(`import { Card } from "#components/ui/card"`)
+
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import { Button } from "#registry/new-york/ui/button"
+import { Card } from "#/registry/new-york/ui/card"
+import * as RegistryRoot from "#registry"
+import * as RegistryRootCompat from "#/registry"
+import { cn } from "#utils"
+import { helper } from "#lib/helpers"
+import { useThing } from "#hooks/use-thing"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ utils: "#utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toContain(`import { cn } from "#utils"`)
+
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import * as RegistryRoot from "#registry"
+import * as RegistryRootCompat from "#/registry"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ utils: "#utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toContain(`import * as RegistryRoot from "#components"`)
+
+ expect(
+ await transform({
+ filename: "test.ts",
+ raw: `import * as RegistryRoot from "#registry"
+import * as RegistryRootCompat from "#/registry"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ utils: "#utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+ ).toContain(`import * as RegistryRootCompat from "#components"`)
+})
+
+test("prefers explicit workspace utils alias over local lib alias", async () => {
+ expect(
+ await transform({
+ filename: "test.tsx",
+ raw: `import { cn } from "@/lib/utils"
+import { helper } from "@/lib/helper"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ lib: "#lib",
+ hooks: "#hooks",
+ ui: "@workspace/ui/components",
+ utils: "@workspace/ui/lib/utils",
+ },
+ },
+ })
+ ).toContain(`import { cn } from "@workspace/ui/lib/utils"`)
+})
+
+test("prefers explicit utils alias for registry lib utils imports", async () => {
+ expect(
+ await transform({
+ filename: "login-form.tsx",
+ raw: `import { cn } from "@/registry/new-york-v4/lib/utils"
+import { Button } from "@/registry/new-york-v4/ui/button"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ lib: "#lib",
+ hooks: "#hooks",
+ ui: "@workspace/ui/components",
+ utils: "@workspace/ui/lib/utils",
+ },
+ },
+ })
+ ).toContain(`import { cn } from "@workspace/ui/lib/utils"`)
+})
+
test("transform async/dynamic imports", async () => {
expect(
await transform({
@@ -324,6 +588,64 @@ async function loadMultiple() {
).toMatchSnapshot()
})
+test("does not rewrite foreign scoped package imports when project uses # aliases", async () => {
+ const result = await transform({
+ filename: "test.tsx",
+ raw: `import { Analytics } from "@vercel/analytics/react"
+import posthog from "posthog-js"
+import { motion } from "motion/react"
+import { Button } from "@/registry/new-york-v4/ui/button"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ utils: "#utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+
+ expect(result).toContain(
+ `import { Analytics } from "@vercel/analytics/react"`
+ )
+ expect(result).toContain(`import posthog from "posthog-js"`)
+ expect(result).toContain(`import { motion } from "motion/react"`)
+ expect(result).toContain(`import { Button } from "#components/ui/button"`)
+})
+
+test("does not rewrite workspace package exports when project uses # aliases", async () => {
+ const result = await transform({
+ filename: "test.tsx",
+ raw: `import { Card } from "@workspace/ui/components/card"
+import { useTheme } from "@workspace/ui/hooks/use-theme"
+import { Button } from "@/registry/new-york-v4/ui/button"
+`,
+ config: {
+ tsx: true,
+ aliases: {
+ components: "#components",
+ ui: "@workspace/ui/components",
+ utils: "@workspace/ui/lib/utils",
+ lib: "#lib",
+ hooks: "#hooks",
+ },
+ },
+ })
+
+ expect(result).toContain(
+ `import { Card } from "@workspace/ui/components/card"`
+ )
+ expect(result).toContain(
+ `import { useTheme } from "@workspace/ui/hooks/use-theme"`
+ )
+ expect(result).toContain(
+ `import { Button } from "@workspace/ui/components/button"`
+ )
+})
+
test("transform re-exports with dynamic imports", async () => {
expect(
await transform({
diff --git a/packages/shadcn/test/utils/updaters/update-files.test.ts b/packages/shadcn/test/utils/updaters/update-files.test.ts
index 8f355f9c7a..dd53690cc4 100644
--- a/packages/shadcn/test/utils/updaters/update-files.test.ts
+++ b/packages/shadcn/test/utils/updaters/update-files.test.ts
@@ -1,6 +1,8 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterAll, afterEach, describe, expect, test, vi } from "vitest"
+import prompts from "prompts"
+import { Project } from "ts-morph"
import { getConfig } from "../../../src/utils/get-config"
import {
@@ -8,6 +10,7 @@ import {
resolveFilePath,
resolveModuleByProbablePath,
resolveNestedFilePath,
+ rewriteResolvedImportsInContent,
toAliasedImport,
updateFiles,
} from "../../../src/utils/updaters/update-files"
@@ -1073,6 +1076,330 @@ return Hello World
`)
})
+ test("should rewrite exact package-import subpaths to valid relative imports", async () => {
+ const tempDir = path.join(
+ path.resolve(__dirname, "../../fixtures"),
+ "temp-package-import-exact-hook"
+ )
+ const fsActual = (await vi.importActual(
+ "fs/promises"
+ )) as typeof import("fs/promises")
+ const fsModuleActual = (await vi.importActual("fs")) as typeof import("fs")
+ const writeFileMock = fs.writeFile as any
+
+ try {
+ writeFileMock.mockImplementation(fsModuleActual.promises.writeFile as any)
+
+ await fsActual.rm(tempDir, { recursive: true, force: true })
+ await fsActual.mkdir(path.join(tempDir, "src", "app"), { recursive: true })
+ await fsActual.mkdir(path.join(tempDir, "src", "hooks"), {
+ recursive: true,
+ })
+ await fsActual.mkdir(path.join(tempDir, "src", "lib"), { recursive: true })
+
+ await fsActual.writeFile(
+ path.join(tempDir, "package.json"),
+ JSON.stringify(
+ {
+ name: "temp-package-import-exact-hook",
+ type: "module",
+ imports: {
+ "#components/*": "./src/components/*",
+ "#hooks": "./src/hooks/index.ts",
+ "#utils": "./src/lib/utils.ts",
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "tsconfig.json"),
+ JSON.stringify(
+ {
+ compilerOptions: {
+ module: "esnext",
+ moduleResolution: "bundler",
+ resolvePackageJsonImports: true,
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "components.json"),
+ JSON.stringify(
+ {
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: true,
+ tsx: true,
+ tailwind: {
+ config: "",
+ css: "src/app/globals.css",
+ baseColor: "zinc",
+ cssVariables: true,
+ },
+ aliases: {
+ components: "#components",
+ hooks: "#hooks",
+ utils: "#utils",
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "src", "app", "globals.css"),
+ '@import "tailwindcss";\n',
+ "utf-8"
+ )
+ await fsActual.writeFile(
+ path.join(tempDir, "src", "hooks", "index.ts"),
+ 'export * from "./use-thing"\n',
+ "utf-8"
+ )
+ await fsActual.writeFile(
+ path.join(tempDir, "src", "lib", "utils.ts"),
+ "export function cn() {}\n",
+ "utf-8"
+ )
+
+ const config = await getConfig(tempDir)
+ if (!config) {
+ throw new Error("Failed to get config")
+ }
+
+ await updateFiles(
+ [
+ {
+ path: "components/example-card.tsx",
+ type: "registry:component",
+ content: `import { useThing } from "@/hooks/use-thing"
+
+export function ExampleCard() {
+ useThing()
+ return null
+}
+`,
+ },
+ {
+ path: "hooks/use-thing.ts",
+ type: "registry:hook",
+ content: `export function useThing() {
+ return true
+}
+`,
+ },
+ ],
+ config,
+ {
+ overwrite: true,
+ silent: true,
+ }
+ )
+
+ const componentContents = await fsActual.readFile(
+ path.join(tempDir, "src", "components", "example-card.tsx"),
+ "utf-8"
+ )
+
+ expect(componentContents).toContain(`from "../hooks/use-thing"`)
+ expect(componentContents).not.toContain(`from "#hooks/use-thing"`)
+ } finally {
+ writeFileMock.mockResolvedValue(undefined)
+ await fsActual.rm(tempDir, { recursive: true, force: true }).catch(() => {})
+ }
+ })
+
+ test("should skip existing package-import files when final content is identical", async () => {
+ const tempDir = path.join(
+ path.resolve(__dirname, "../../fixtures"),
+ "temp-package-import-same-content"
+ )
+ const fsActual = (await vi.importActual(
+ "fs/promises"
+ )) as typeof import("fs/promises")
+ const fsModuleActual = (await vi.importActual("fs")) as typeof import("fs")
+ const writeFileMock = fs.writeFile as any
+
+ try {
+ writeFileMock.mockImplementation(fsModuleActual.promises.writeFile as any)
+
+ await fsActual.rm(tempDir, { recursive: true, force: true })
+ await fsActual.mkdir(path.join(tempDir, "src", "components", "ui"), {
+ recursive: true,
+ })
+ await fsActual.mkdir(path.join(tempDir, "src", "lib"), {
+ recursive: true,
+ })
+
+ await fsActual.writeFile(
+ path.join(tempDir, "package.json"),
+ JSON.stringify(
+ {
+ name: "temp-package-import-same-content",
+ type: "module",
+ imports: {
+ "#components/*": "./src/components/*",
+ "#lib/*": "./src/lib/*",
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "tsconfig.json"),
+ JSON.stringify(
+ {
+ files: [],
+ references: [{ path: "./tsconfig.app.json" }],
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "tsconfig.app.json"),
+ JSON.stringify(
+ {
+ compilerOptions: {
+ module: "esnext",
+ moduleResolution: "bundler",
+ baseUrl: ".",
+ paths: {
+ "#components/*": ["./src/components/*"],
+ "#lib/*": ["./src/lib/*"],
+ },
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "components.json"),
+ JSON.stringify(
+ {
+ $schema: "https://ui.shadcn.com/schema.json",
+ style: "new-york",
+ rsc: false,
+ tsx: true,
+ tailwind: {
+ config: "",
+ css: "src/index.css",
+ baseColor: "zinc",
+ cssVariables: true,
+ },
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ lib: "#lib",
+ utils: "#lib/utils",
+ },
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "src", "index.css"),
+ '@import "tailwindcss";\n',
+ "utf-8"
+ )
+
+ await fsActual.writeFile(
+ path.join(tempDir, "src", "lib", "utils.ts"),
+ "export function cn(...inputs: unknown[]) {\n return inputs\n}\n",
+ "utf-8"
+ )
+
+ const config = await getConfig(tempDir)
+ if (!config) {
+ throw new Error("Failed to get config")
+ }
+
+ const buttonFile = {
+ path: "registry/default/ui/button.tsx",
+ type: "registry:ui" as const,
+ content: `import { cn } from "@/lib/utils"
+
+export function Button() {
+ return
+}
+`,
+ }
+
+ await updateFiles([buttonFile], config, {
+ overwrite: true,
+ silent: true,
+ })
+
+ vi.mocked(prompts).mockClear()
+
+ const result = await updateFiles([buttonFile], config, {
+ overwrite: false,
+ silent: true,
+ })
+
+ expect(result.filesSkipped).toEqual(["src/components/ui/button.tsx"])
+ expect(result.filesUpdated).toEqual([])
+ expect(vi.mocked(prompts)).not.toHaveBeenCalled()
+ } finally {
+ writeFileMock.mockResolvedValue(undefined)
+ await fsActual.rm(tempDir, { recursive: true, force: true }).catch(() => {})
+ }
+ })
+
+ test("should remove temporary source files after rewriting content", async () => {
+ const project = new Project({
+ compilerOptions: {},
+ })
+ const content = "export const value = 1\n"
+
+ await expect(
+ rewriteResolvedImportsInContent({
+ content,
+ resolvedPath: "/tmp/example.ts",
+ filePaths: [],
+ config: {
+ aliases: {},
+ resolvedPaths: {
+ cwd: "/tmp",
+ },
+ } as any,
+ projectInfo: {
+ aliasPrefix: "#",
+ } as any,
+ tsConfig: {
+ resultType: "success",
+ absoluteBaseUrl: "/tmp",
+ paths: {},
+ } as any,
+ project,
+ })
+ ).resolves.toBe(content)
+
+ expect(project.getSourceFiles()).toHaveLength(0)
+ })
+
test("should mark .env file as created when it doesn't exist", async () => {
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
@@ -1099,6 +1426,48 @@ ANOTHER_NEW_KEY=another_value`,
expect(result.filesUpdated).not.toContain(".env")
})
+ test("should rewrite app-local files to workspace utils aliases in monorepos without tsconfig paths", async () => {
+ const config = await getConfig(
+ path.resolve(
+ __dirname,
+ "../../fixtures/frameworks/vite-monorepo-imports/apps/web"
+ )
+ )
+
+ if (!config) {
+ throw new Error("Failed to get monorepo app config")
+ }
+
+ const result = await updateFiles(
+ [
+ {
+ path: "registry/components/login-form.tsx",
+ type: "registry:component",
+ content: `import { cn } from "@/lib/utils"
+
+export function LoginForm() {
+ return {cn("login")}
+}
+`,
+ },
+ ],
+ config,
+ {
+ overwrite: true,
+ silent: true,
+ }
+ )
+
+ expect(result.filesCreated).toContain("src/components/login-form.tsx")
+
+ const writtenContent = (fs.writeFile as any).mock.calls.find((call: any) =>
+ call[0].endsWith("src/components/login-form.tsx")
+ )?.[1]
+
+ expect(writtenContent).toContain(`from "@workspace/ui/lib/utils"`)
+ expect(writtenContent).not.toContain(`from "#lib/utils"`)
+ })
+
test("should mark .env file as updated when merging content", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
@@ -1968,4 +2337,96 @@ describe("toAliasedImport", () => {
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home")
})
+
+ test("should preserve extensions for package imports that target bare wildcards", () => {
+ const filePath = "src/components/ui/button.tsx"
+ const config = {
+ resolvedPaths: {
+ cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
+ components: path.resolve(
+ __dirname,
+ "../../fixtures/config-imports/src/components"
+ ),
+ ui: path.resolve(
+ __dirname,
+ "../../fixtures/config-imports/src/components/ui"
+ ),
+ },
+ aliases: {
+ components: "#components",
+ ui: "#components/ui",
+ },
+ }
+ const projectInfo = {
+ aliasPrefix: "#",
+ }
+
+ expect(toAliasedImport(filePath, config, projectInfo)).toBe(
+ "#components/ui/button.tsx"
+ )
+ })
+
+ test("should strip extensions for package imports whose target already includes them", () => {
+ const filePath = "src/components/button.tsx"
+ const config = {
+ resolvedPaths: {
+ cwd: path.resolve(__dirname, "../../fixtures/with-package-imports"),
+ components: path.resolve(
+ __dirname,
+ "../../fixtures/with-package-imports/src/components"
+ ),
+ },
+ aliases: {
+ components: "#components-ext",
+ },
+ }
+ const projectInfo = {
+ aliasPrefix: "#",
+ }
+
+ expect(toAliasedImport(filePath, config, projectInfo)).toBe(
+ "#components-ext/button"
+ )
+ })
+
+ test("should keep exact package import aliases for index files", () => {
+ const filePath = "src/hooks/index.ts"
+ const config = {
+ resolvedPaths: {
+ cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
+ hooks: path.resolve(__dirname, "../../fixtures/config-imports/src/hooks"),
+ },
+ aliases: {
+ hooks: "#hooks",
+ },
+ }
+ const projectInfo = {
+ aliasPrefix: "#",
+ }
+
+ expect(toAliasedImport(filePath, config, projectInfo)).toBe("#hooks")
+ })
+
+ test("should prefer exact package import aliases over parent directory aliases", () => {
+ const filePath = "src/lib/utils.ts"
+ const config = {
+ resolvedPaths: {
+ cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
+ lib: path.resolve(__dirname, "../../fixtures/config-imports/src/lib"),
+ utils: path.resolve(
+ __dirname,
+ "../../fixtures/config-imports/src/lib/utils.ts"
+ ),
+ },
+ aliases: {
+ lib: "#lib",
+ utils: "#utils",
+ },
+ }
+ const projectInfo = {
+ aliasPrefix: "#",
+ }
+
+ expect(toAliasedImport(filePath, config, projectInfo)).toBe("#utils")
+ })
})
diff --git a/packages/tests/fixtures/next-app-imports/components.json b/packages/tests/fixtures/next-app-imports/components.json
new file mode 100644
index 0000000000..78d69794b5
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "#components",
+ "utils": "#utils",
+ "ui": "#components/ui",
+ "lib": "#lib",
+ "hooks": "#hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/packages/tests/fixtures/next-app-imports/next.config.ts b/packages/tests/fixtures/next-app-imports/next.config.ts
new file mode 100644
index 0000000000..1d6147825a
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/next.config.ts
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {}
+
+export default nextConfig
diff --git a/packages/tests/fixtures/next-app-imports/package.json b/packages/tests/fixtures/next-app-imports/package.json
new file mode 100644
index 0000000000..bd376790a9
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "next-app-imports",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "imports": {
+ "#components/*": "./src/components/*.tsx",
+ "#components/ui/*": "./src/components/ui/*.tsx",
+ "#lib/*": "./src/lib/*.ts",
+ "#hooks/*": "./src/hooks/*.ts",
+ "#hooks": "./src/hooks/index.ts",
+ "#utils": "./src/lib/utils.ts"
+ },
+ "dependencies": {
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.527.0",
+ "next": "15.4.10",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "tailwind-merge": "^3.3.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "tw-animate-css": "^1.3.6",
+ "typescript": "^5"
+ }
+}
diff --git a/packages/tests/fixtures/next-app-imports/postcss.config.mjs b/packages/tests/fixtures/next-app-imports/postcss.config.mjs
new file mode 100644
index 0000000000..f4aa8a8fed
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+}
+
+export default config
diff --git a/packages/tests/fixtures/next-app-imports/src/app/globals.css b/packages/tests/fixtures/next-app-imports/src/app/globals.css
new file mode 100644
index 0000000000..e74e30ee45
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/src/app/globals.css
@@ -0,0 +1,123 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/packages/tests/fixtures/next-app-imports/src/app/layout.tsx b/packages/tests/fixtures/next-app-imports/src/app/layout.tsx
new file mode 100644
index 0000000000..ef077d2d94
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/src/app/layout.tsx
@@ -0,0 +1,22 @@
+import "./globals.css"
+import type { Metadata } from "next"
+import { Inter } from "next/font/google"
+
+const inter = Inter({ subsets: ["latin"] })
+
+export const metadata: Metadata = {
+ title: "Create Next App",
+ description: "Generated by create next app",
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/tests/fixtures/next-app-imports/src/app/page.tsx b/packages/tests/fixtures/next-app-imports/src/app/page.tsx
new file mode 100644
index 0000000000..69ea63d4cd
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/src/app/page.tsx
@@ -0,0 +1,3 @@
+export default function Home() {
+ return Hello
+}
diff --git a/packages/tests/fixtures/next-app-imports/src/lib/utils.ts b/packages/tests/fixtures/next-app-imports/src/lib/utils.ts
new file mode 100644
index 0000000000..bd0c391ddd
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/packages/tests/fixtures/next-app-imports/tsconfig.json b/packages/tests/fixtures/next-app-imports/tsconfig.json
new file mode 100644
index 0000000000..baa432b1fd
--- /dev/null
+++ b/packages/tests/fixtures/next-app-imports/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolvePackageJsonImports": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/apps/web/components.json b/packages/tests/fixtures/vite-monorepo-imports/apps/web/components.json
new file mode 100644
index 0000000000..e664fbf6f2
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/apps/web/components.json
@@ -0,0 +1,18 @@
+{
+ "style": "new-york",
+ "tailwind": {
+ "config": "",
+ "css": "../../packages/ui/src/styles/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "rsc": false,
+ "tsx": true,
+ "aliases": {
+ "components": "#components",
+ "ui": "@workspace/ui/components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "@workspace/ui/lib/utils"
+ }
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/apps/web/package.json b/packages/tests/fixtures/vite-monorepo-imports/apps/web/package.json
new file mode 100644
index 0000000000..f86ed0e5d0
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/apps/web/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "web",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#*": "./src/*"
+ },
+ "dependencies": {
+ "@workspace/ui": "workspace:*",
+ "tailwindcss": "^4.2.1"
+ }
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/apps/web/src/main.tsx b/packages/tests/fixtures/vite-monorepo-imports/apps/web/src/main.tsx
new file mode 100644
index 0000000000..aceb444975
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/apps/web/src/main.tsx
@@ -0,0 +1 @@
+console.log("web")
diff --git a/packages/tests/fixtures/vite-monorepo-imports/apps/web/tsconfig.json b/packages/tests/fixtures/vite-monorepo-imports/apps/web/tsconfig.json
new file mode 100644
index 0000000000..f89fad37f9
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/apps/web/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/apps/web/vite.config.ts b/packages/tests/fixtures/vite-monorepo-imports/apps/web/vite.config.ts
new file mode 100644
index 0000000000..15652d9d07
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/apps/web/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from "vite"
+
+export default defineConfig({})
diff --git a/packages/tests/fixtures/vite-monorepo-imports/package.json b/packages/tests/fixtures/vite-monorepo-imports/package.json
new file mode 100644
index 0000000000..0b30673cfd
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "vite-monorepo-imports",
+ "private": true,
+ "workspaces": ["apps/*", "packages/*"]
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/packages/ui/components.json b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/components.json
new file mode 100644
index 0000000000..70cd00c27b
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/components.json
@@ -0,0 +1,18 @@
+{
+ "style": "new-york",
+ "tailwind": {
+ "config": "",
+ "css": "src/styles/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "rsc": false,
+ "tsx": true,
+ "aliases": {
+ "components": "#components",
+ "ui": "#components",
+ "lib": "#lib",
+ "hooks": "#hooks",
+ "utils": "#lib/utils"
+ }
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/packages/ui/package.json b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/package.json
new file mode 100644
index 0000000000..1d00279428
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@workspace/ui",
+ "private": true,
+ "type": "module",
+ "imports": {
+ "#*": "./src/*"
+ },
+ "exports": {
+ "./globals.css": "./src/styles/globals.css",
+ "./components/*": "./src/components/*.tsx",
+ "./lib/*": "./src/lib/*.ts",
+ "./hooks/*": "./src/hooks/*.ts"
+ }
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/lib/utils.ts b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/lib/utils.ts
new file mode 100644
index 0000000000..02903b9115
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/lib/utils.ts
@@ -0,0 +1,3 @@
+export function cn(...classes: Array) {
+ return classes.filter(Boolean).join(" ")
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/styles/globals.css b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/styles/globals.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/styles/globals.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/packages/tests/fixtures/vite-monorepo-imports/packages/ui/tsconfig.json b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/tsconfig.json
new file mode 100644
index 0000000000..f89fad37f9
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/packages/ui/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolvePackageJsonImports": true
+ }
+}
diff --git a/packages/tests/fixtures/vite-monorepo-imports/pnpm-workspace.yaml b/packages/tests/fixtures/vite-monorepo-imports/pnpm-workspace.yaml
new file mode 100644
index 0000000000..286cf7f564
--- /dev/null
+++ b/packages/tests/fixtures/vite-monorepo-imports/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - apps/*
+ - packages/*
diff --git a/packages/tests/src/tests/add.test.ts b/packages/tests/src/tests/add.test.ts
index 9904e385d9..c811cda348 100644
--- a/packages/tests/src/tests/add.test.ts
+++ b/packages/tests/src/tests/add.test.ts
@@ -8,10 +8,24 @@ import {
getRegistryUrl,
npxShadcn,
} from "../utils/helpers"
+import { configureRegistries, createRegistryServer } from "../utils/registry"
// Note: The tests here intentionally do not use a mocked registry.
// We test this against the real registry.
+function expectCommandSuccess(result: Awaited>) {
+ expect(
+ result.exitCode,
+ [
+ `Expected command to exit with 0, got ${result.exitCode}.`,
+ "stdout:",
+ result.stdout || "",
+ "stderr:",
+ result.stderr || "",
+ ].join("\n")
+ ).toBe(0)
+}
+
describe("shadcn add", () => {
it("should add item to project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
@@ -166,6 +180,39 @@ describe("shadcn add", () => {
).toBe("Foo Bar")
})
+ it("should preview add changes without writing files", async () => {
+ const fixturePath = await createFixtureTestDirectory("next-app-init")
+
+ const result = await npxShadcn(fixturePath, ["add", "button", "--dry-run"])
+
+ expectCommandSuccess(result)
+ expect(result.stdout).toContain("shadcn add button (dry run)")
+ expect(result.stdout).toContain("components/ui/button.tsx")
+ expect(result.stdout).toContain("Run without --dry-run to apply.")
+ expect(
+ await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
+ ).toBe(false)
+ })
+
+ it("should show no changes for identical files with diff", async () => {
+ const fixturePath = await createFixtureTestDirectory("next-app-init")
+
+ await npxShadcn(fixturePath, ["add", "button", "--yes"])
+
+ const result = await npxShadcn(fixturePath, [
+ "add",
+ "button",
+ "--diff",
+ "button",
+ "--yes",
+ ])
+
+ expectCommandSuccess(result)
+ expect(result.stdout).toContain("shadcn add button (dry run)")
+ expect(result.stdout).toContain("components/ui/button.tsx (skip)")
+ expect(result.stdout).toContain("No changes.")
+ })
+
it("should add item with target to src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
@@ -182,12 +229,12 @@ describe("shadcn add", () => {
})
it("should add item with target to root", async () => {
- const fixturePath = await createFixtureTestDirectory("next-app")
- await npxShadcn(fixturePath, ["init", "--defaults"])
- await npxShadcn(fixturePath, [
+ const fixturePath = await createFixtureTestDirectory("next-app-init")
+ const result = await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-item-to-root.json",
])
+ expectCommandSuccess(result)
expect(await fs.pathExists(path.join(fixturePath, "config.json"))).toBe(
true
)
@@ -230,6 +277,106 @@ describe("shadcn add", () => {
`)
})
+ it("should add monorepo components and rewrite app-local imports with package imports", async () => {
+ const fixturePath = await createFixtureTestDirectory(
+ "vite-monorepo-imports"
+ )
+
+ const result = await npxShadcn(
+ fixturePath,
+ ["add", "login-03", "-c", "apps/web", "--yes"],
+ { timeout: 300000 }
+ )
+
+ expectCommandSuccess(result)
+
+ expect(
+ await fs.pathExists(
+ path.join(fixturePath, "apps/web/src/components/login-form.tsx")
+ )
+ ).toBe(true)
+ expect(
+ await fs.pathExists(
+ path.join(fixturePath, "packages/ui/src/components/button.tsx")
+ )
+ ).toBe(true)
+ expect(
+ await fs.pathExists(
+ path.join(fixturePath, "apps/web/src/components/ui/button.tsx")
+ )
+ ).toBe(false)
+
+ const loginFormContent = await fs.readFile(
+ path.join(fixturePath, "apps/web/src/components/login-form.tsx"),
+ "utf-8"
+ )
+ expect(loginFormContent).toContain(
+ 'import { cn } from "@workspace/ui/lib/utils"'
+ )
+ expect(loginFormContent).toContain(
+ 'import { Button } from "@workspace/ui/components/button"'
+ )
+
+ const buttonContent = await fs.readFile(
+ path.join(fixturePath, "packages/ui/src/components/button.tsx"),
+ "utf-8"
+ )
+ expect(buttonContent).toContain('import { cn } from "#lib/utils.ts"')
+ }, 300000)
+
+ it("should preview monorepo adds without writing files", async () => {
+ const fixturePath = await createFixtureTestDirectory(
+ "vite-monorepo-imports"
+ )
+
+ const result = await npxShadcn(
+ fixturePath,
+ ["add", "login-03", "-c", "apps/web", "--dry-run", "--yes"],
+ { timeout: 300000 }
+ )
+
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain("shadcn add login-03 (dry run)")
+ expect(result.stdout).toContain(
+ "../../packages/ui/src/components/button.tsx"
+ )
+ expect(result.stdout).toContain("src/components/login-form.tsx")
+ expect(
+ await fs.pathExists(
+ path.join(fixturePath, "apps/web/src/components/login-form.tsx")
+ )
+ ).toBe(false)
+ expect(
+ await fs.pathExists(
+ path.join(fixturePath, "packages/ui/src/components/button.tsx")
+ )
+ ).toBe(false)
+ }, 300000)
+
+ it("should show no changes for identical monorepo files with diff", async () => {
+ const fixturePath = await createFixtureTestDirectory(
+ "vite-monorepo-imports"
+ )
+
+ const setupResult = await npxShadcn(
+ fixturePath,
+ ["add", "login-03", "-c", "apps/web", "--yes"],
+ { timeout: 300000 }
+ )
+ expectCommandSuccess(setupResult)
+
+ const result = await npxShadcn(
+ fixturePath,
+ ["add", "login-03", "-c", "apps/web", "--diff", "login-form", "--yes"],
+ { timeout: 300000 }
+ )
+
+ expectCommandSuccess(result)
+ expect(result.stdout).toContain("shadcn add login-03 (dry run)")
+ expect(result.stdout).toContain("src/components/login-form.tsx (skip)")
+ expect(result.stdout).toContain("No changes.")
+ }, 300000)
+
it("should add NOT update existing envVars", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
@@ -342,6 +489,146 @@ describe("shadcn add", () => {
).toBe(false)
})
+ it("should add component to a single-package #imports project", async () => {
+ const fixturePath = await createFixtureTestDirectory("next-app-imports")
+
+ const result = await npxShadcn(fixturePath, ["add", "button", "--yes"])
+
+ expectCommandSuccess(result)
+
+ const buttonPath = path.join(fixturePath, "src/components/ui/button.tsx")
+ expect(await fs.pathExists(buttonPath)).toBe(true)
+
+ const buttonContent = await fs.readFile(buttonPath, "utf-8")
+ expect(buttonContent).toContain('import { cn } from "#utils"')
+ expect(buttonContent).not.toContain("@/lib/utils")
+ expect(buttonContent).not.toContain("@/registry/")
+ })
+
+ it("should add multi-file block to a single-package #imports project", async () => {
+ const fixturePath = await createFixtureTestDirectory("next-app-imports")
+
+ const result = await npxShadcn(fixturePath, ["add", "login-03", "--yes"])
+
+ expectCommandSuccess(result)
+
+ const loginFormPath = path.join(
+ fixturePath,
+ "src/components/login-form.tsx"
+ )
+ const buttonPath = path.join(fixturePath, "src/components/ui/button.tsx")
+ expect(await fs.pathExists(loginFormPath)).toBe(true)
+ expect(await fs.pathExists(buttonPath)).toBe(true)
+
+ const loginFormContent = await fs.readFile(loginFormPath, "utf-8")
+ expect(loginFormContent).toContain('import { cn } from "#utils"')
+ expect(loginFormContent).toContain(
+ 'import { Button } from "#components/ui/button"'
+ )
+ expect(loginFormContent).not.toContain("@/registry/")
+ })
+
+ it("should preview --dry-run for a single-package #imports project", async () => {
+ const fixturePath = await createFixtureTestDirectory("next-app-imports")
+
+ const result = await npxShadcn(fixturePath, [
+ "add",
+ "button",
+ "--dry-run",
+ "--yes",
+ ])
+
+ expectCommandSuccess(result)
+ expect(result.stdout).toContain("shadcn add button (dry run)")
+ expect(result.stdout).toContain("src/components/ui/button.tsx")
+ expect(result.stdout).toContain("Run without --dry-run to apply.")
+ expect(
+ await fs.pathExists(
+ path.join(fixturePath, "src/components/ui/button.tsx")
+ )
+ ).toBe(false)
+ })
+
+ it("should show --diff no-op for identical content in a #imports project", async () => {
+ const fixturePath = await createFixtureTestDirectory("next-app-imports")
+
+ const setup = await npxShadcn(fixturePath, ["add", "button", "--yes"])
+ expectCommandSuccess(setup)
+
+ const result = await npxShadcn(fixturePath, [
+ "add",
+ "button",
+ "--diff",
+ "button",
+ "--yes",
+ ])
+
+ expectCommandSuccess(result)
+ expect(result.stdout).toContain("shadcn add button (dry run)")
+ expect(result.stdout).toContain("src/components/ui/button.tsx (skip)")
+ expect(result.stdout).toContain("No changes.")
+ })
+
+ it("should add namespaced registry item to a #imports project", async () => {
+ const registry = await createRegistryServer(
+ [
+ {
+ name: "fancy-card",
+ type: "registry:component",
+ registryDependencies: ["button"],
+ files: [
+ {
+ path: "components/fancy-card.tsx",
+ type: "registry:component",
+ content: `import { Button } from "@/registry/new-york-v4/ui/button"
+import { cn } from "@/lib/utils"
+
+export function FancyCard() {
+ return
+}
+`,
+ },
+ ],
+ },
+ ],
+ {
+ port: 4454,
+ }
+ )
+ await registry.start()
+
+ try {
+ const fixturePath = await createFixtureTestDirectory("next-app-imports")
+ await configureRegistries(fixturePath, {
+ "@one": "http://localhost:4454/r/{name}",
+ })
+
+ const result = await npxShadcn(fixturePath, [
+ "add",
+ "@one/fancy-card",
+ "--yes",
+ ])
+
+ expectCommandSuccess(result)
+
+ const cardPath = path.join(fixturePath, "src/components/fancy-card.tsx")
+ const buttonPath = path.join(fixturePath, "src/components/ui/button.tsx")
+
+ expect(await fs.pathExists(cardPath)).toBe(true)
+ expect(await fs.pathExists(buttonPath)).toBe(true)
+
+ const cardContent = await fs.readFile(cardPath, "utf-8")
+ expect(cardContent).toContain(
+ 'import { Button } from "#components/ui/button"'
+ )
+ expect(cardContent).toContain('import { cn } from "#utils"')
+ expect(cardContent).not.toContain("@/registry/")
+ expect(cardContent).not.toContain("@/lib/utils")
+ } finally {
+ await registry.stop()
+ }
+ })
+
it("should add at-property", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await npxShadcn(fixturePath, [