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