Compare commits

...

26 Commits

Author SHA1 Message Date
shadcn
b1238440cb Merge branch 'main' into shadcn/package-imports 2026-04-08 02:57:19 +04:00
shadcn
6737c01997 Merge branch 'shadcn/package-imports' of github.com:shadcn-ui/ui into shadcn/package-imports 2026-04-07 18:14:53 +04:00
shadcn
77f4639edd fix 2026-04-07 18:13:52 +04:00
shadcn
421d52333e chore: changeset 2026-04-07 16:47:48 +04:00
shadcn
5002ee0e4b Merge branch 'main' into shadcn/package-imports 2026-04-07 16:44:33 +04:00
shadcn
515013c8b1 fix 2026-03-16 21:35:29 +04:00
shadcn
9145b52df0 fix
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-16 21:12:27 +04:00
shadcn
2649a1f6e4 Merge branch 'main' into shadcn/package-imports 2026-03-16 20:55:37 +04:00
shadcn
b6f3b8eaa2 fix 2026-03-16 19:48:04 +04:00
shadcn
5e69f18010 docs 2026-03-16 19:41:39 +04:00
shadcn
8297097512 fix: refactor 2026-03-16 19:21:49 +04:00
shadcn
a434fada95 fix: update test coverage 2026-03-16 17:18:05 +04:00
shadcn
0d7a005714 fix 2026-03-16 16:53:59 +04:00
shadcn
2b0dc2116a fix 2026-03-16 16:49:31 +04:00
shadcn
aaf8c0770c fix 2026-03-16 16:42:58 +04:00
shadcn
e135d1895f fix 2026-03-16 16:42:05 +04:00
shadcn
70fbec5258 fix 2026-03-16 16:40:08 +04:00
shadcn
503a895520 fix 2026-03-16 16:37:38 +04:00
shadcn
3f0fefd12b fix 2026-03-16 16:32:54 +04:00
shadcn
c96b35b66e fix 2026-03-16 16:00:51 +04:00
shadcn
08fcda032a fix 2026-03-16 15:02:15 +04:00
shadcn
04cbfb73ad fix 2026-03-16 14:58:14 +04:00
shadcn
36d0b07a0c fix 2026-03-16 14:50:18 +04:00
shadcn
83f5d46b6e fix: refactor 2026-03-16 13:48:31 +04:00
shadcn
ef35fd8f4c fix: refactor 2026-03-16 13:27:13 +04:00
shadcn
b6cfe91aa6 feat: initial commit for subpath 2026-03-16 12:56:52 +04:00
80 changed files with 4175 additions and 171 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add support for package imports

View File

@@ -140,15 +140,87 @@ 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`.
<Callout className="mt-6">
**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.
</Callout>
### 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/*",
"#lib/*": "./src/lib/*",
"#hooks/*": "./src/hooks/*"
}
}
```
```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/*"` can generate imports like
`#components/button.tsx`
- `"#components/*": "./src/components/*.tsx"` generates imports like
`#components/button`
For monorepos, see the <Link href="/docs/monorepo">monorepo docs</Link>. 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`.
### aliases.utils
Import alias for your utility functions.

View File

@@ -164,3 +164,85 @@ 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.
<Callout className="mt-6">
`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.
</Callout>
## 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": {
"#*": "./src/*"
},
"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": {
"#*": "./src/*"
},
"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

View File

@@ -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/*",
"#lib/*": "./src/lib/*",
"#hooks/*": "./src/hooks/*"
}
}
```
```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 <Link href="/docs/components-json">components.json docs</Link> for the
matching aliases and package imports behavior.
### 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.

View File

@@ -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"
@@ -565,6 +566,7 @@ export async function runInit(
}
) {
let projectInfo
let projectConfig
let newProjectTemplate: keyof typeof templates | undefined
// Resolve the effective template if --monorepo is set.
@@ -606,6 +608,8 @@ export async function runInit(
projectInfo = await getProjectInfo(options.cwd)
}
projectConfig = await getProjectConfig(options.cwd, projectInfo)
// Use the template from project creation if available,
// or fall back to the explicit --template flag.
const templateKey = newProjectTemplate ?? explicitTemplate
@@ -619,6 +623,9 @@ export async function runInit(
// Add button component for new template-based projects.
...(selectedTemplate ? ["button"] : []),
]
const templatePostInit = options.isNewProject
? selectedTemplate?.postInit
: undefined
if (selectedTemplate?.init) {
const result = await selectedTemplate.init({
@@ -632,15 +639,15 @@ export async function runInit(
silent: options.silent,
})
// Run postInit for new projects (e.g. git init).
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))
@@ -770,9 +777,9 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
// Run postInit for new projects without a custom init (e.g. git init).
if (selectedTemplate) {
await selectedTemplate.postInit({ projectPath: options.cwd })
// Run postInit only for newly scaffolded projects.
if (templatePostInit) {
await templatePostInit({ projectPath: options.cwd })
}
return fullConfig
@@ -856,12 +863,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",
@@ -876,6 +877,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,
@@ -889,11 +900,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,
},
})
}

View File

@@ -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()
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof loadConfig>
try {
tsConfig = loadConfig(config.resolvedPaths.cwd)
} catch {
tsConfig = { resultType: "failed" } as ReturnType<typeof loadConfig>
}
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",
})

View File

@@ -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<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
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<z.infer<typeof rawConfigSchema> | 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)

View File

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

View File

@@ -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(stripJsonComments(contents))
} catch {
continue
}
const result = TS_CONFIG_SCHEMA.safeParse(parsed)
if (result.error) {
continue
@@ -360,16 +404,89 @@ export async function getTsConfig(cwd: string) {
return null
}
function stripJsonComments(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
}
result += current
}
return result
}
export async function getProjectConfig(
cwd: string,
defaultProjectInfo: ProjectInfo | null = null
): Promise<Config | null> {
// 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 +501,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<typeof rawConfigSchema> = {
$schema: "https://ui.shadcn.com/schema.json",
rsc: projectInfo.isRSC,
@@ -397,18 +543,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<Config["resolvedPaths"], "cwd">
}): Promise<TailwindVersion> {

View File

@@ -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<string, unknown>))
}
}
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}`
}

View File

@@ -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<string, PackageImportEntry[]>()
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<string[]>((shared, segments, index) => {
if (!index) {
return segments
}
return shared.filter((segment, segmentIndex) => {
return segments[segmentIndex] === segment
})
}, [])
return sharedSegments.length ? `#${sharedSegments.join("/")}` : "#"
}

View File

@@ -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<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
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
}

View File

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

View File

@@ -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,11 @@ 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"]
export async function updateFiles(
files: RegistryItem["files"],
config: Config,
@@ -73,6 +79,15 @@ export async function updateFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
const tsConfig = loadConfig(config.resolvedPaths.cwd)
const plannedFilePaths = getPlannedFilePaths(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 +191,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,
@@ -554,66 +578,175 @@ async function resolveImports(filePaths: string[], config: Config) {
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
continue
}
const rewrittenContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths,
project,
projectInfo,
resolvedPath,
sourceFile,
tsConfig,
})
const importDeclarations = sourceFile.getImportDeclarations()
for (const importDeclaration of importDeclarations) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
// Filter out non-local imports.
if (
projectInfo?.aliasPrefix &&
!moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`)
) {
continue
}
// Find the probable import file path.
// This is where we expect to find the file on disk.
const probableImportFilePath = await resolveImport(
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
)
if (!resolvedImportFilePath) {
continue
}
// Convert the resolved import file path to an aliased import.
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
projectInfo
)
if (!newImport || newImport === moduleSpecifier) {
continue
}
importDeclaration.setModuleSpecifier(newImport)
// Write the updated content to the file.
await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8")
// Track the updated file.
updatedFiles.push(filepath)
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<typeof file> => !!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<typeof loadConfig>
project: Project
sourceFile?: ReturnType<Project["createSourceFile"]>
}) {
if (!projectInfo || tsConfig.resultType === "failed") {
return content
}
const ext = path.extname(resolvedPath)
if (![".tsx", ".ts", ".jsx", ".js"].includes(ext)) {
return content
}
const workingSourceFile =
sourceFile ??
project.createSourceFile(
path.join(
tmpdir(),
`shadcn-${Math.random().toString(36).slice(2)}${ext || ".tsx"}`
),
content,
{
scriptKind: ScriptKind.TSX,
overwrite: true,
}
)
let hasChanges = false
for (const importDeclaration of workingSourceFile.getImportDeclarations()) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
if (!isLocalAliasImport(moduleSpecifier, projectInfo.aliasPrefix ?? null)) {
continue
}
const resolvedImportFilePath = await resolveImportFilePathForRewrite(
moduleSpecifier,
filePaths,
config,
tsConfig
)
if (!resolvedImportFilePath) {
continue
}
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
projectInfo,
resolvedPath
)
if (!newImport || newImport === moduleSpecifier) {
continue
}
importDeclaration.setModuleSpecifier(newImport)
hasChanges = true
}
return hasChanges ? workingSourceFile.getFullText() : content
}
async function resolveImportFilePathForRewrite(
moduleSpecifier: string,
filePaths: string[],
config: Config,
tsConfig: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
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
)
}
/**
* Given an absolute "probable" import path (no ext),
* plus an array of absolute file paths you already know about,
@@ -694,7 +827,8 @@ 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))
@@ -716,10 +850,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 +883,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<typeof resolvePackageImport> extends infer T
? Exclude<T, null>
: 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,

View File

@@ -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<string, WorkspacePackageInfo>
>()
const workspaceExportEntriesCache = new Map<
string,
WorkspacePackageExportEntry[]
>()
const workspaceRootCache = new Map<string, string | null>()
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<string, WorkspacePackageInfo>()
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
}

View File

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

View File

@@ -0,0 +1,8 @@
{
"name": "config-imports-extensions",
"type": "module",
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View File

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

View File

@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello</div>
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
export default {}

View File

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

View File

@@ -0,0 +1,12 @@
{
"name": "vite-app-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"tailwindcss": "^4.1.11"
},
"imports": {
"#custom/*": "./src/*"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

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

View File

@@ -0,0 +1,12 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "vite-monorepo-imports",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export function cn(...inputs: Array<string | undefined | false | null>) {
return inputs.filter(Boolean).join(" ")
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

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

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,12 @@
{
"name": "vite-root-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"tailwindcss": "^4.1.11"
},
"imports": {
"#*": "./src/*"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"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",
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
}
}

View File

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

View File

@@ -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> = {}): 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 <button>{cn("button")}</button>
}
`,
"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 <button>{cn("button")}</button>
}
`,
},
],
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 <div>{cn("login")}</div>
}
`,
},
],
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", () => {

View File

@@ -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", () => {

View File

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

View File

@@ -29,6 +29,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(

View File

@@ -1,8 +1,12 @@
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 {
isLocalAliasImport,
resolveImport,
resolveImportWithMetadata,
} from "../../src/utils/resolve-import"
test("resolve import", async () => {
expect(
@@ -79,3 +83,114 @@ 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("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",
})
})
})
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
)
})
})

View File

@@ -34,6 +34,49 @@ test('transform nested workspace folder for utils, website/src/utils', async ()
})
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 +219,53 @@ 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 for monorepo", async () => {
expect(
await transform({
@@ -228,6 +318,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({

View File

@@ -1,6 +1,7 @@
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 { getConfig } from "../../../src/utils/get-config"
import {
@@ -1073,6 +1074,298 @@ return <div>Hello World</div>
`)
})
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 <button>{cn("button")}</button>
}
`,
}
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 mark .env file as created when it doesn't exist", async () => {
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
@@ -1099,6 +1392,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 <div>{cn("login")}</div>
}
`,
},
],
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 +2303,73 @@ 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")
})
})

View File

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

View File

@@ -0,0 +1,12 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1 @@
console.log("web")

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,6 @@
{
"name": "vite-monorepo-imports",
"private": true,
"packageManager": "pnpm@10.0.0",
"workspaces": ["apps/*", "packages/*"]
}

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ")
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

View File

@@ -166,6 +166,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"])
expect(result.exitCode).toBe(0)
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",
])
expect(result.exitCode).toBe(0)
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 +215,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",
])
expect(result.exitCode).toBe(0)
expect(await fs.pathExists(path.join(fixturePath, "config.json"))).toBe(
true
)
@@ -230,6 +263,105 @@ 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 }
)
expect(result.exitCode).toBe(0)
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"
)
await npxShadcn(
fixturePath,
["add", "login-03", "-c", "apps/web", "--yes"],
{ timeout: 300000 }
)
const result = await npxShadcn(
fixturePath,
["add", "login-03", "-c", "apps/web", "--diff", "login-form", "--yes"],
{ timeout: 300000 }
)
expect(result.exitCode).toBe(0)
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"])