mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
Compare commits
26 Commits
shadcn@4.3
...
shadcn/pac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1238440cb | ||
|
|
6737c01997 | ||
|
|
77f4639edd | ||
|
|
421d52333e | ||
|
|
5002ee0e4b | ||
|
|
515013c8b1 | ||
|
|
9145b52df0 | ||
|
|
2649a1f6e4 | ||
|
|
b6f3b8eaa2 | ||
|
|
5e69f18010 | ||
|
|
8297097512 | ||
|
|
a434fada95 | ||
|
|
0d7a005714 | ||
|
|
2b0dc2116a | ||
|
|
aaf8c0770c | ||
|
|
e135d1895f | ||
|
|
70fbec5258 | ||
|
|
503a895520 | ||
|
|
3f0fefd12b | ||
|
|
c96b35b66e | ||
|
|
08fcda032a | ||
|
|
04cbfb73ad | ||
|
|
36d0b07a0c | ||
|
|
83f5d46b6e | ||
|
|
ef35fd8f4c | ||
|
|
b6cfe91aa6 |
5
.changeset/few-flies-fry.md
Normal file
5
.changeset/few-flies-fry.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add support for package imports
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
140
packages/shadcn/src/preflights/preflight-init.test.ts
Normal file
140
packages/shadcn/src/preflights/preflight-init.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
77
packages/shadcn/src/utils/alias.test.ts
Normal file
77
packages/shadcn/src/utils/alias.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
62
packages/shadcn/src/utils/alias.ts
Normal file
62
packages/shadcn/src/utils/alias.ts
Normal 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 ""
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
156
packages/shadcn/src/utils/import-matcher.ts
Normal file
156
packages/shadcn/src/utils/import-matcher.ts
Normal 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}`
|
||||
}
|
||||
185
packages/shadcn/src/utils/package-imports.ts
Normal file
185
packages/shadcn/src/utils/package-imports.ts
Normal 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("/")}` : "#"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
251
packages/shadcn/src/utils/workspace.ts
Normal file
251
packages/shadcn/src/utils/workspace.ts
Normal 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
|
||||
}
|
||||
16
packages/shadcn/test/fixtures/config-imports-extensions/components.json
vendored
Normal file
16
packages/shadcn/test/fixtures/config-imports-extensions/components.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/shadcn/test/fixtures/config-imports-extensions/package.json
vendored
Normal file
8
packages/shadcn/test/fixtures/config-imports-extensions/package.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "config-imports-extensions",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#components/*": "./src/components/*.tsx",
|
||||
"#lib/*": "./src/lib/*.ts"
|
||||
}
|
||||
}
|
||||
1
packages/shadcn/test/fixtures/config-imports-extensions/src/index.css
vendored
Normal file
1
packages/shadcn/test/fixtures/config-imports-extensions/src/index.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
8
packages/shadcn/test/fixtures/config-imports-extensions/tsconfig.json
vendored
Normal file
8
packages/shadcn/test/fixtures/config-imports-extensions/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
18
packages/shadcn/test/fixtures/config-imports/components.json
vendored
Normal file
18
packages/shadcn/test/fixtures/config-imports/components.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
11
packages/shadcn/test/fixtures/config-imports/package.json
vendored
Normal file
11
packages/shadcn/test/fixtures/config-imports/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/shadcn/test/fixtures/config-imports/tsconfig.json
vendored
Normal file
8
packages/shadcn/test/fixtures/config-imports/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
4
packages/shadcn/test/fixtures/frameworks/next-app-imports/next.config.js
vendored
Normal file
4
packages/shadcn/test/fixtures/frameworks/next-app-imports/next.config.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
export default nextConfig
|
||||
21
packages/shadcn/test/fixtures/frameworks/next-app-imports/package.json
vendored
Normal file
21
packages/shadcn/test/fixtures/frameworks/next-app-imports/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
11
packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/layout.tsx
vendored
Normal file
11
packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/layout.tsx
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
3
packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/page.tsx
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/page.tsx
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>Hello</div>
|
||||
}
|
||||
3
packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/styles.css
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/styles.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
1
packages/shadcn/test/fixtures/frameworks/next-app-imports/tailwind.config.ts
vendored
Normal file
1
packages/shadcn/test/fixtures/frameworks/next-app-imports/tailwind.config.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export default {}
|
||||
25
packages/shadcn/test/fixtures/frameworks/next-app-imports/tsconfig.json
vendored
Normal file
25
packages/shadcn/test/fixtures/frameworks/next-app-imports/tsconfig.json
vendored
Normal 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"]
|
||||
}
|
||||
12
packages/shadcn/test/fixtures/frameworks/vite-app-imports/package.json
vendored
Normal file
12
packages/shadcn/test/fixtures/frameworks/vite-app-imports/package.json
vendored
Normal 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/*"
|
||||
}
|
||||
}
|
||||
1
packages/shadcn/test/fixtures/frameworks/vite-app-imports/src/index.css
vendored
Normal file
1
packages/shadcn/test/fixtures/frameworks/vite-app-imports/src/index.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
7
packages/shadcn/test/fixtures/frameworks/vite-app-imports/tsconfig.json
vendored
Normal file
7
packages/shadcn/test/fixtures/frameworks/vite-app-imports/tsconfig.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
3
packages/shadcn/test/fixtures/frameworks/vite-app-imports/vite.config.ts
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/vite-app-imports/vite.config.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({})
|
||||
18
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/components.json
vendored
Normal file
18
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/components.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
12
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/package.json
vendored
Normal file
12
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/package.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@workspace/ui": "workspace:*",
|
||||
"tailwindcss": "^4.2.1"
|
||||
}
|
||||
}
|
||||
8
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/tsconfig.json
vendored
Normal file
8
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
5
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/package.json
vendored
Normal file
5
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "vite-monorepo-imports",
|
||||
"private": true,
|
||||
"workspaces": ["apps/*", "packages/*"]
|
||||
}
|
||||
18
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/components.json
vendored
Normal file
18
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/components.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
14
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/package.json
vendored
Normal file
14
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cn(...inputs: Array<string | undefined | false | null>) {
|
||||
return inputs.filter(Boolean).join(" ")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
8
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/tsconfig.json
vendored
Normal file
8
packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
13
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/package.json
vendored
Normal file
13
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/src/index.css
vendored
Normal file
1
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/src/index.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
12
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.app.json
vendored
Normal file
12
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.app.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#components/*": ["./src/components/*"],
|
||||
"#lib/*": ["./src/lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.json
vendored
Normal file
4
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
3
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/vite.config.ts
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/vite-partial-imports/vite.config.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({})
|
||||
12
packages/shadcn/test/fixtures/frameworks/vite-root-imports/package.json
vendored
Normal file
12
packages/shadcn/test/fixtures/frameworks/vite-root-imports/package.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "vite-root-imports",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
}
|
||||
}
|
||||
1
packages/shadcn/test/fixtures/frameworks/vite-root-imports/src/index.css
vendored
Normal file
1
packages/shadcn/test/fixtures/frameworks/vite-root-imports/src/index.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
7
packages/shadcn/test/fixtures/frameworks/vite-root-imports/tsconfig.json
vendored
Normal file
7
packages/shadcn/test/fixtures/frameworks/vite-root-imports/tsconfig.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
3
packages/shadcn/test/fixtures/frameworks/vite-root-imports/vite.config.ts
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/vite-root-imports/vite.config.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({})
|
||||
7
packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.app.json
vendored
Normal file
7
packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.app.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.json
vendored
Normal file
10
packages/shadcn/test/fixtures/frameworks/vite-root-paths/tsconfig.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/shadcn/test/fixtures/with-package-imports/package.json
vendored
Normal file
14
packages/shadcn/test/fixtures/with-package-imports/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
18
packages/tests/fixtures/vite-monorepo-imports/apps/web/components.json
vendored
Normal file
18
packages/tests/fixtures/vite-monorepo-imports/apps/web/components.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
12
packages/tests/fixtures/vite-monorepo-imports/apps/web/package.json
vendored
Normal file
12
packages/tests/fixtures/vite-monorepo-imports/apps/web/package.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@workspace/ui": "workspace:*",
|
||||
"tailwindcss": "^4.2.1"
|
||||
}
|
||||
}
|
||||
1
packages/tests/fixtures/vite-monorepo-imports/apps/web/src/main.tsx
vendored
Normal file
1
packages/tests/fixtures/vite-monorepo-imports/apps/web/src/main.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
||||
console.log("web")
|
||||
8
packages/tests/fixtures/vite-monorepo-imports/apps/web/tsconfig.json
vendored
Normal file
8
packages/tests/fixtures/vite-monorepo-imports/apps/web/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
3
packages/tests/fixtures/vite-monorepo-imports/apps/web/vite.config.ts
vendored
Normal file
3
packages/tests/fixtures/vite-monorepo-imports/apps/web/vite.config.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({})
|
||||
6
packages/tests/fixtures/vite-monorepo-imports/package.json
vendored
Normal file
6
packages/tests/fixtures/vite-monorepo-imports/package.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "vite-monorepo-imports",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.0.0",
|
||||
"workspaces": ["apps/*", "packages/*"]
|
||||
}
|
||||
18
packages/tests/fixtures/vite-monorepo-imports/packages/ui/components.json
vendored
Normal file
18
packages/tests/fixtures/vite-monorepo-imports/packages/ui/components.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
14
packages/tests/fixtures/vite-monorepo-imports/packages/ui/package.json
vendored
Normal file
14
packages/tests/fixtures/vite-monorepo-imports/packages/ui/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/lib/utils.ts
vendored
Normal file
3
packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/lib/utils.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
1
packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/styles/globals.css
vendored
Normal file
1
packages/tests/fixtures/vite-monorepo-imports/packages/ui/src/styles/globals.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
8
packages/tests/fixtures/vite-monorepo-imports/packages/ui/tsconfig.json
vendored
Normal file
8
packages/tests/fixtures/vite-monorepo-imports/packages/ui/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolvePackageJsonImports": true
|
||||
}
|
||||
}
|
||||
3
packages/tests/fixtures/vite-monorepo-imports/pnpm-workspace.yaml
vendored
Normal file
3
packages/tests/fixtures/vite-monorepo-imports/pnpm-workspace.yaml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user