From b6cfe91aa6e169c7369e45f366158afa0934f8e0 Mon Sep 17 00:00:00 2001 From: shadcn Date: Mon, 16 Mar 2026 12:56:52 +0400 Subject: [PATCH] feat: initial commit for subpath --- packages/shadcn/src/commands/init.ts | 120 +++++- .../shadcn/src/preflights/preflight-init.ts | 17 +- packages/shadcn/src/registry/utils.ts | 17 +- packages/shadcn/src/utils/dry-run.ts | 54 ++- packages/shadcn/src/utils/get-config.ts | 98 ++++- .../shadcn/src/utils/get-monorepo-info.ts | 2 +- packages/shadcn/src/utils/get-project-info.ts | 122 +++++- packages/shadcn/src/utils/package-imports.ts | 298 +++++++++++++ packages/shadcn/src/utils/resolve-import.ts | 142 +++++- .../utils/transformers/transform-import.ts | 113 ++++- .../shadcn/src/utils/updaters/update-files.ts | 387 ++++++++++++++--- .../src/utils/workspace-package-exports.ts | 339 +++++++++++++++ packages/shadcn/test/commands/init.test.ts | 76 ++++ .../config-imports-extensions/components.json | 16 + .../config-imports-extensions/package.json | 8 + .../config-imports-extensions/src/index.css | 1 + .../config-imports-extensions/tsconfig.json | 8 + .../fixtures/config-imports/components.json | 18 + .../test/fixtures/config-imports/package.json | 11 + .../fixtures/config-imports/tsconfig.json | 8 + .../next-app-imports/next.config.js | 4 + .../frameworks/next-app-imports/package.json | 21 + .../next-app-imports/src/app/layout.tsx | 11 + .../next-app-imports/src/app/page.tsx | 3 + .../next-app-imports/src/app/styles.css | 3 + .../next-app-imports/tailwind.config.ts | 1 + .../frameworks/next-app-imports/tsconfig.json | 25 ++ .../frameworks/vite-app-imports/package.json | 12 + .../frameworks/vite-app-imports/src/index.css | 1 + .../frameworks/vite-app-imports/tsconfig.json | 7 + .../vite-app-imports/vite.config.ts | 3 + .../apps/web/components.json | 18 + .../apps/web/package.json | 12 + .../apps/web/tsconfig.json | 8 + .../vite-monorepo-imports/package.json | 5 + .../packages/ui/components.json | 18 + .../packages/ui/package.json | 14 + .../packages/ui/src/lib/utils.ts | 3 + .../packages/ui/src/styles/globals.css | 1 + .../packages/ui/tsconfig.json | 8 + .../vite-partial-imports/package.json | 13 + .../vite-partial-imports/src/index.css | 1 + .../vite-partial-imports/tsconfig.app.json | 12 + .../vite-partial-imports/tsconfig.json | 4 + .../vite-partial-imports/vite.config.ts | 3 + .../with-package-imports/package.json | 14 + .../test/preflights/preflight-init.test.ts | 133 ++++++ .../transform-import.test.ts.snap | 2 +- packages/shadcn/test/utils/dry-run.test.ts | 254 +++++++++++ packages/shadcn/test/utils/get-config.test.ts | 335 +++++++++++++++ .../test/utils/get-project-info.test.ts | 42 ++ .../shadcn/test/utils/resolve-import.test.ts | 112 ++++- .../test/utils/transform-import.test.ts | 178 ++++++++ .../test/utils/updaters/update-files.test.ts | 404 ++++++++++++++++++ 54 files changed, 3415 insertions(+), 125 deletions(-) create mode 100644 packages/shadcn/src/utils/package-imports.ts create mode 100644 packages/shadcn/src/utils/workspace-package-exports.ts create mode 100644 packages/shadcn/test/commands/init.test.ts create mode 100644 packages/shadcn/test/fixtures/config-imports-extensions/components.json create mode 100644 packages/shadcn/test/fixtures/config-imports-extensions/package.json create mode 100644 packages/shadcn/test/fixtures/config-imports-extensions/src/index.css create mode 100644 packages/shadcn/test/fixtures/config-imports-extensions/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/config-imports/components.json create mode 100644 packages/shadcn/test/fixtures/config-imports/package.json create mode 100644 packages/shadcn/test/fixtures/config-imports/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/next.config.js create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/package.json create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/layout.tsx create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/page.tsx create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/src/app/styles.css create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/tailwind.config.ts create mode 100644 packages/shadcn/test/fixtures/frameworks/next-app-imports/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-app-imports/package.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-app-imports/src/index.css create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-app-imports/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-app-imports/vite.config.ts create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/components.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/package.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/apps/web/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/package.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/components.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/package.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-monorepo-imports/packages/ui/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-partial-imports/package.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-partial-imports/src/index.css create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.app.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-partial-imports/tsconfig.json create mode 100644 packages/shadcn/test/fixtures/frameworks/vite-partial-imports/vite.config.ts create mode 100644 packages/shadcn/test/fixtures/with-package-imports/package.json create mode 100644 packages/shadcn/test/preflights/preflight-init.test.ts diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 9fa9446fac..d3dece84de 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -632,8 +632,10 @@ export async function runInit( silent: options.silent, }) - // Run postInit for new projects (e.g. git init). - await selectedTemplate.postInit({ projectPath: options.cwd }) + if (shouldRunTemplatePostInit(selectedTemplate, options.isNewProject)) { + // Run postInit for newly scaffolded projects (e.g. git init). + await selectedTemplate.postInit({ projectPath: options.cwd }) + } return result } @@ -770,9 +772,9 @@ export async function runInit( options.isNewProject || projectInfo?.framework.name === "next-app", }) - // Run postInit for new projects without a custom init (e.g. git init). - if (selectedTemplate) { - await selectedTemplate.postInit({ projectPath: options.cwd }) + // Run postInit only for newly scaffolded projects. + if (shouldRunTemplatePostInit(selectedTemplate, options.isNewProject)) { + await selectedTemplate!.postInit!({ projectPath: options.cwd }) } return fullConfig @@ -856,11 +858,46 @@ async function promptForConfig(defaultConfig: Config | null = null) { )}:`, initial: defaultConfig?.aliases["components"] ?? DEFAULT_COMPONENTS, }, + ]) + + if (!options.style) { + process.exit(1) + } + + const existingAliases = + defaultConfig && defaultConfig.aliases.components === options.components + ? defaultConfig.aliases + : undefined + + const aliasDefaults = getInitAliasDefaults( + options.components, + existingAliases + ) + + const aliasOptions = await prompts([ + { + type: "text", + name: "ui", + message: `Configure the import alias for ${highlighter.info("ui")}:`, + initial: aliasDefaults.ui, + }, + { + type: "text", + name: "lib", + message: `Configure the import alias for ${highlighter.info("lib")}:`, + initial: aliasDefaults.lib, + }, + { + type: "text", + name: "hooks", + message: `Configure the import alias for ${highlighter.info("hooks")}:`, + initial: aliasDefaults.hooks, + }, { type: "text", name: "utils", message: `Configure the import alias for ${highlighter.info("utils")}:`, - initial: defaultConfig?.aliases["utils"] ?? DEFAULT_UTILS, + initial: aliasDefaults.utils, }, { type: "toggle", @@ -872,10 +909,6 @@ async function promptForConfig(defaultConfig: Config | null = null) { }, ]) - if (!options.style) { - process.exit(1) - } - return rawConfigSchema.parse({ $schema: "https://ui.shadcn.com/schema.json", style: options.style, @@ -886,18 +919,75 @@ async function promptForConfig(defaultConfig: Config | null = null) { cssVariables: options.tailwindCssVariables, prefix: options.tailwindPrefix, }, - rsc: options.rsc, + rsc: aliasOptions.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: aliasOptions.ui, + lib: aliasOptions.lib, + hooks: aliasOptions.hooks, + utils: aliasOptions.utils, }, }) } +export function getInitAliasDefaults( + componentsAlias: string, + existingAliases?: Config["aliases"] +) { + const derivedLib = + existingAliases?.lib ?? deriveSiblingAlias(componentsAlias, "lib") + + return { + ui: existingAliases?.ui ?? deriveUiAlias(componentsAlias), + lib: derivedLib, + hooks: + existingAliases?.hooks ?? deriveSiblingAlias(componentsAlias, "hooks"), + utils: + existingAliases?.utils ?? deriveUtilsAlias(componentsAlias, derivedLib), + } +} + +function deriveUiAlias(componentsAlias: string) { + return componentsAlias ? `${componentsAlias}/ui` : `${DEFAULT_COMPONENTS}/ui` +} + +function deriveUtilsAlias(componentsAlias: string, libAlias: string) { + if (componentsAlias === "#components") { + return "#utils" + } + + return libAlias ? `${libAlias}/utils` : DEFAULT_UTILS +} + +function deriveSiblingAlias(componentsAlias: string, segment: "lib" | "hooks") { + if (componentsAlias === "components") { + return segment + } + + if (componentsAlias.endsWith("/components")) { + return `${componentsAlias.slice(0, -"/components".length)}/${segment}` + } + + if ( + componentsAlias.endsWith("components") && + !componentsAlias.includes("/") + ) { + return `${componentsAlias.slice(0, -"components".length)}${segment}` + } + + return "" +} + +export function shouldRunTemplatePostInit( + template: + | { postInit?: (options: { projectPath: string }) => Promise } + | undefined, + isNewProject: boolean | undefined +) { + return Boolean(template?.postInit && isNewProject) +} + async function promptForMinimalConfig( defaultConfig: Config, opts: z.infer diff --git a/packages/shadcn/src/preflights/preflight-init.ts b/packages/shadcn/src/preflights/preflight-init.ts index be5340fe37..29ecb4193c 100644 --- a/packages/shadcn/src/preflights/preflight-init.ts +++ b/packages/shadcn/src/preflights/preflight-init.ts @@ -129,14 +129,14 @@ export async function preFlightInit( tailwindSpinner?.succeed() } - const tsConfigSpinner = spinner(`Validating import alias.`, { + const aliasSpinner = spinner(`Validating import alias.`, { silent: options.silent, }).start() if (!projectInfo?.aliasPrefix) { errors[ERRORS.IMPORT_ALIAS_MISSING] = true - tsConfigSpinner?.fail() + aliasSpinner?.fail() } else { - tsConfigSpinner?.succeed() + aliasSpinner?.succeed() } if (Object.keys(errors).length > 0) { @@ -162,7 +162,16 @@ export async function preFlightInit( if (errors[ERRORS.IMPORT_ALIAS_MISSING]) { logger.break() - logger.error(`No import alias found in your tsconfig.json file.`) + logger.error( + `No import alias found in your ${highlighter.info( + "tsconfig.json" + )} or ${highlighter.info("package.json#imports")} configuration.` + ) + logger.error( + `Add an alias in ${highlighter.info( + "compilerOptions.paths" + )} or ${highlighter.info('"imports"')} and try again.` + ) if (projectInfo?.framework.links.installation) { logger.error( `Visit ${highlighter.info( 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/dry-run.ts b/packages/shadcn/src/utils/dry-run.ts index 6c6779d6cc..20e181aa03 100644 --- a/packages/shadcn/src/utils/dry-run.ts +++ b/packages/shadcn/src/utils/dry-run.ts @@ -23,8 +23,11 @@ import { transformCssVars } from "@/src/utils/updaters/update-css-vars" import { findCommonRoot, 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 = { @@ -140,6 +143,41 @@ 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 = files + .filter((file): file is NonNullable => !!file?.content) + .map((file, index) => { + let plannedFilePath = resolveFilePath(file, config, { + isSrcDir: projectInfo?.isSrcDir, + framework: projectInfo?.framework.name, + commonRoot: findCommonRoot( + files.map((entry) => entry.path), + file.path + ), + fileIndex: index, + }) + + if (!plannedFilePath) { + return null + } + + if (!config.tsx) { + plannedFilePath = plannedFilePath.replace(/\.tsx?$/, (match) => + match === ".tsx" ? ".jsx" : ".js" + ) + } + + return path.relative(config.resolvedPaths.cwd, plannedFilePath) + }) + .filter((filePath): filePath is string => !!filePath) for (let index = 0; index < files.length; index++) { const file = files[index] @@ -197,13 +235,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" @@ -213,7 +263,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..f59d5f33fb 100644 --- a/packages/shadcn/src/utils/get-config.ts +++ b/packages/shadcn/src/utils/get-config.ts @@ -7,10 +7,13 @@ 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 { + resolveImport, + 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" @@ -72,28 +75,56 @@ 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), + utils: await resolveAliasPath( + "utils", + config.aliases["utils"], + cwd, + tsConfig + ), + components: await resolveAliasPath( + "components", + config.aliases["components"], + cwd, + tsConfig + ), ui: config.aliases["ui"] - ? await resolveImport(config.aliases["ui"], tsConfig) + ? await resolveAliasPath("ui", config.aliases["ui"], cwd, tsConfig) : path.resolve( - (await resolveImport(config.aliases["components"], tsConfig)) ?? + (await resolveAliasPath( + "components", + config.aliases["components"], cwd, + tsConfig + )) ?? cwd, "ui" ), // 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) + ? await resolveAliasPath("lib", config.aliases["lib"], cwd, tsConfig) : path.resolve( - (await resolveImport(config.aliases["utils"], tsConfig)) ?? cwd, + (await resolveAliasPath( + "utils", + config.aliases["utils"], + cwd, + tsConfig + )) ?? cwd, ".." ), hooks: config.aliases["hooks"] - ? await resolveImport(config.aliases["hooks"], tsConfig) + ? await resolveAliasPath( + "hooks", + config.aliases["hooks"], + cwd, + tsConfig + ) : path.resolve( - (await resolveImport(config.aliases["components"], tsConfig)) ?? + (await resolveAliasPath( + "components", + config.aliases["components"], cwd, + tsConfig + )) ?? cwd, "..", "hooks" ), @@ -101,6 +132,53 @@ export async function resolveConfigPaths( }) } +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 await resolveImport(alias, { ...tsConfig, cwd }) + } + + if ( + aliasKey !== "utils" && + ["package_imports", "workspace_package_exports"].includes( + resolved.source + ) && + !resolved.matchedAlias.includes("*") && + /\/index\.[^/]+$/.test(resolved.path) + ) { + // Exact package-import aliases like `#hooks`, and exact workspace package + // exports that point at index files, should resolve to directory roots for + // components/hooks/ui/lib. `utils` stays file-based by design. + return path.dirname(resolved.path) + } + + if ( + aliasKey !== "utils" && + ["package_imports", "workspace_package_exports"].includes( + resolved.source + ) && + resolved.matchedAlias.includes("*") && + /\.[^/]+$/.test(resolved.path) + ) { + // Wildcard package-import aliases and workspace package exports stored in + // components.json represent directory roots, even when the matched target + // is extension-based (`./src/components/*.tsx`). Strip the source extension + // so `ui` resolves to `/src/components/ui` instead of `/src/components/ui.tsx`. + return resolved.path.replace(/\.[^/]+$/, "") + } + + return resolved.path +} + export async function getRawConfig( cwd: string ): Promise | null> { diff --git a/packages/shadcn/src/utils/get-monorepo-info.ts b/packages/shadcn/src/utils/get-monorepo-info.ts index 7a6c969d81..a08d8edc60 100644 --- a/packages/shadcn/src/utils/get-monorepo-info.ts +++ b/packages/shadcn/src/utils/get-monorepo-info.ts @@ -122,7 +122,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..a9de7dd197 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. @@ -324,6 +328,31 @@ export async function getTsConfigAliasPrefix(cwd: string) { return Object.keys(tsConfig?.paths)?.[0].replace(/\/\*$/, "") ?? null } +export async function getProjectAliasInfo(cwd: string) { + const tsConfigAliasPrefix = await getTsConfigAliasPrefix(cwd) + + if (tsConfigAliasPrefix) { + return { + prefix: tsConfigAliasPrefix, + source: "tsconfig_paths" as const, + } + } + + const packageImportPrefix = getPackageImportPrefix(cwd) + + if (packageImportPrefix) { + return { + prefix: packageImportPrefix, + source: "package_imports" as const, + } + } + + return { + prefix: null, + source: null, + } +} + export async function isTypeScriptProject(cwd: string) { const files = await fg.glob("tsconfig.*", { cwd, @@ -365,11 +394,12 @@ export async function getProjectConfig( 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 +414,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 +456,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/package-imports.ts b/packages/shadcn/src/utils/package-imports.ts new file mode 100644 index 0000000000..9a97175083 --- /dev/null +++ b/packages/shadcn/src/utils/package-imports.ts @@ -0,0 +1,298 @@ +import path from "path" +import { getPackageInfo } from "@/src/utils/get-package-info" + +export type ImportEmitMode = "strip_extension" | "preserve_extension" + +export type PackageImportEntry = { + key: string + aliasBase: string + target: string + emitMode: ImportEmitMode + hasWildcard: boolean +} + +export type PackageImportMatch = { + path: string + matchedAlias: string + matchedTarget: string + emitMode: ImportEmitMode +} + +const packageImportEntriesCache = new Map() + +export function getPackageImportEntries(cwd: string): PackageImportEntry[] { + 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 = resolveLocalImportTarget(value) + if (!target) { + continue + } + + entries.push({ + key, + aliasBase: key.endsWith("/*") ? key.slice(0, -2) : key, + target, + emitMode: getImportEmitMode(target), + hasWildcard: key.includes("*"), + }) + } + + 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 +): PackageImportMatch | null { + const entries = getPackageImportEntries(cwd) + + const exactMatch = entries.find( + (entry) => !entry.hasWildcard && entry.key === importPath + ) + if (exactMatch) { + return { + path: path.resolve(cwd, 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 [keyPrefix, keySuffix] = entry.key.split("*") + const wildcardValue = getWildcardValue(importPath, keyPrefix, keySuffix) + + if (wildcardValue === null) { + continue + } + + return { + path: path.resolve(cwd, applyWildcardTarget(entry.target, wildcardValue)), + matchedAlias: entry.key, + matchedTarget: entry.target, + emitMode: entry.emitMode, + } + } + + return null +} + +export function getPackageImportAliases(cwd: string) { + const entries = getPackageImportEntries(cwd) + + return { + components: findBestAlias(entries, "components"), + ui: findBestAlias(entries, "ui"), + lib: findBestAlias(entries, "lib"), + hooks: findBestAlias(entries, "hooks"), + utils: findBestAlias(entries, "utils"), + } +} + +function resolveLocalImportTarget(target: unknown): string | null { + if (typeof target === "string") { + return target.startsWith(".") ? target : null + } + + if (Array.isArray(target)) { + for (const value of target) { + const resolved = resolveLocalImportTarget(value) + if (resolved) { + return resolved + } + } + + return null + } + + if (target && typeof target === "object") { + for (const value of Object.values(target as Record)) { + const resolved = resolveLocalImportTarget(value) + if (resolved) { + return resolved + } + } + } + + return null +} + +function getImportEmitMode(target: string): ImportEmitMode { + 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" +} + +function getWildcardValue( + importPath: string, + prefix: string, + suffix: string +): string | null { + if (importPath.startsWith(prefix) && importPath.endsWith(suffix)) { + return suffix + ? importPath.slice(prefix.length, -suffix.length) + : importPath.slice(prefix.length) + } + + // `components.json` stores alias roots like `#components`, not raw + // `package.json#imports` patterns like `#components/*`, so we accept the + // bare alias base here to keep package-import support schema-compatible. + if ( + suffix === "" && + prefix.endsWith("/") && + importPath === prefix.slice(0, -1) + ) { + return "" + } + + return null +} + +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}` +} + +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..62f1c35b2a 100644 --- a/packages/shadcn/src/utils/resolve-import.ts +++ b/packages/shadcn/src/utils/resolve-import.ts @@ -1,13 +1,147 @@ +import { + resolvePackageImport, + type ImportEmitMode, +} from "@/src/utils/package-imports" +import { resolveWorkspacePackageExport } from "@/src/utils/workspace-package-exports" import { createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths" -export async function resolveImport( +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: Pick -) { - return createMatchPath(config.absoluteBaseUrl, config.paths)( + config: ResolveImportConfig +): Promise { + const cwd = config.cwd ?? config.absoluteBaseUrl + + if (importPath.startsWith("#")) { + const packageImport = resolvePackageImport(importPath, cwd) + + if (packageImport) { + return { + path: packageImport.path, + source: "package_imports", + matchedAlias: packageImport.matchedAlias, + matchedTarget: packageImport.matchedTarget, + emitMode: packageImport.emitMode, + } + } + } + + const workspacePackageExport = await resolveWorkspacePackageExport( + importPath, + cwd + ) + + if (workspacePackageExport) { + return { + path: workspacePackageExport.path, + source: "workspace_package_exports", + matchedAlias: workspacePackageExport.matchedAlias, + matchedTarget: workspacePackageExport.matchedTarget, + emitMode: workspacePackageExport.emitMode, + } + } + + 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", + } +} + +export async function resolveImport( + importPath: string, + config: ResolveImportConfig +) { + return (await resolveImportWithMetadata(importPath, config))?.path ?? null +} + +export function isLocalAliasImport( + moduleSpecifier: string, + aliasPrefix: string | null +) { + if (moduleSpecifier.startsWith("#")) { + return true + } + + if (!aliasPrefix) { + return false + } + + return moduleSpecifier.startsWith(`${aliasPrefix}/`) +} + +function isScopedPackageSpecifier(importPath: string) { + return /^@[^/]+\/[^/]+(?:\/.*)?$/.test(importPath) +} + +function findMatchingTsPathPattern( + importPath: string, + paths: ConfigLoaderSuccessResult["paths"] +) { + for (const [key, targets] of Object.entries(paths)) { + const targetList = Array.isArray(targets) ? targets : [targets] + const wildcardValue = getWildcardValue(importPath, key) + + if (wildcardValue === null) { + continue + } + + return { + key, + target: + targetList[0]?.includes("*") && wildcardValue !== null + ? targetList[0].replace("*", wildcardValue) + : targetList[0], + } + } + + return null +} + +function getWildcardValue(importPath: string, pattern: string) { + if (!pattern.includes("*")) { + return importPath === pattern ? "" : null + } + + const [prefix, suffix = ""] = pattern.split("*") + if (!importPath.startsWith(prefix) || !importPath.endsWith(suffix)) { + return null + } + + return suffix + ? importPath.slice(prefix.length, -suffix.length) + : importPath.slice(prefix.length) } diff --git a/packages/shadcn/src/utils/transformers/transform-import.ts b/packages/shadcn/src/utils/transformers/transform-import.ts index 59177f9f78..1b8ae160c2 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,65 @@ function updateImportAliases( config.aliases.components ) } + +function getWorkspaceAliasFromUtilsAlias(utilsAlias: string) { + 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 901d9666d0..dfff3522ee 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" @@ -71,6 +75,15 @@ export async function updateFiles( ? getRegistryBaseColor(config.tailwind.baseColor) : Promise.resolve(undefined), ]) + const tsConfig = loadConfig(config.resolvedPaths.cwd) + const plannedFilePaths = getPlannedFilePaths(files, config, { + isSrcDir: projectInfo?.isSrcDir, + framework: projectInfo?.framework.name, + path: options.path, + }) + const importRewriteProject = new Project({ + compilerOptions: {}, + }) let filesCreated: string[] = [] let filesUpdated: string[] = [] @@ -172,10 +185,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, @@ -550,66 +572,161 @@ async function resolveImports(filePaths: string[], config: Config) { if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) { continue } + const rewrittenContent = await rewriteResolvedImportsInContent({ + config, + content, + filePaths, + project, + projectInfo, + resolvedPath, + sourceFile, + tsConfig, + }) - const importDeclarations = sourceFile.getImportDeclarations() - for (const importDeclaration of importDeclarations) { - const moduleSpecifier = importDeclaration.getModuleSpecifierValue() - - // Filter out non-local imports. - if ( - projectInfo?.aliasPrefix && - !moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`) - ) { - continue - } - - // Find the probable import file path. - // This is where we expect to find the file on disk. - const probableImportFilePath = await resolveImport( - moduleSpecifier, - tsConfig - ) - - if (!probableImportFilePath) { - continue - } - - // Find the actual import file path. - // This is the path where the file has been installed. - const resolvedImportFilePath = resolveModuleByProbablePath( - probableImportFilePath, - filePaths, - config - ) - - if (!resolvedImportFilePath) { - continue - } - - // Convert the resolved import file path to an aliased import. - const newImport = toAliasedImport( - resolvedImportFilePath, - config, - projectInfo - ) - - if (!newImport || newImport === moduleSpecifier) { - continue - } - - importDeclaration.setModuleSpecifier(newImport) - - // Write the updated content to the file. - await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8") - - // Track the updated file. - updatedFiles.push(filepath) + if (rewrittenContent === content) { + continue } + + await fs.writeFile(resolvedPath, rewrittenContent, "utf-8") + updatedFiles.push(filepath) } return updatedFiles } +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 workingSourceFile = + sourceFile ?? + project.createSourceFile( + path.join( + tmpdir(), + `shadcn-${Math.random().toString(36).slice(2)}${ext || ".tsx"}` + ), + content, + { + scriptKind: ScriptKind.TSX, + overwrite: true, + } + ) + + let hasChanges = false + + for (const importDeclaration of workingSourceFile.getImportDeclarations()) { + const moduleSpecifier = importDeclaration.getModuleSpecifierValue() + + if (!isLocalAliasImport(moduleSpecifier, projectInfo.aliasPrefix ?? null)) { + continue + } + + const probableImportFilePath = ( + await resolveImportWithMetadata(moduleSpecifier, { + ...tsConfig, + cwd: config.resolvedPaths.cwd, + }) + )?.path + + const fallbackImportFilePath = + !probableImportFilePath && !moduleSpecifier.startsWith(".") + ? resolveImportFromConfiguredAliases(moduleSpecifier, config) + : null + + if (!probableImportFilePath && !fallbackImportFilePath) { + continue + } + + const resolvedImportFilePath = resolveModuleByProbablePath( + probableImportFilePath ?? fallbackImportFilePath!, + filePaths, + config + ) + + if (!resolvedImportFilePath) { + continue + } + + const newImport = toAliasedImport( + resolvedImportFilePath, + config, + projectInfo, + resolvedPath + ) + + if (!newImport || newImport === moduleSpecifier) { + continue + } + + importDeclaration.setModuleSpecifier(newImport) + hasChanges = true + } + + return hasChanges ? workingSourceFile.getFullText() : content +} + /** * Given an absolute "probable" import path (no ext), * plus an array of absolute file paths you already know about, @@ -690,7 +807,8 @@ export function resolveModuleByProbablePath( export function toAliasedImport( filePath: string, config: Config, - projectInfo: ProjectInfo + projectInfo: ProjectInfo, + importerPath?: string ): string | null { const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath)) @@ -712,6 +830,28 @@ 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"] @@ -724,26 +864,139 @@ 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 codeExts = [".ts", ".tsx", ".js", ".jsx"] + const keepExt = + codeExts.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 codeExts = [".ts", ".tsx", ".js", ".jsx"] + const keepExt = codeExts.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-package-exports.ts b/packages/shadcn/src/utils/workspace-package-exports.ts new file mode 100644 index 0000000000..71e047bec6 --- /dev/null +++ b/packages/shadcn/src/utils/workspace-package-exports.ts @@ -0,0 +1,339 @@ +import path from "path" +import { getWorkspacePatterns } from "@/src/utils/get-monorepo-info" +import { getPackageInfo } from "@/src/utils/get-package-info" +import { type ImportEmitMode } from "@/src/utils/package-imports" +import fg from "fast-glob" + +type WorkspacePackageInfo = { + packageName: string + packageRoot: string +} + +type WorkspacePackageExportEntry = { + key: string + aliasBase: string + target: string + emitMode: ImportEmitMode + hasWildcard: boolean + packageRoot: string +} + +export type WorkspacePackageExportMatch = { + path: string + matchedAlias: string + matchedTarget: string + emitMode: ImportEmitMode +} + +const workspacePackageCache = new Map< + string, + Map +>() +const workspaceExportEntriesCache = new Map< + string, + WorkspacePackageExportEntry[] +>() + +export async function resolveWorkspacePackageExport( + importPath: string, + cwd: string +): Promise { + const specifier = parsePackageSpecifier(importPath) + + if (!specifier) { + return null + } + + const workspacePackage = await findWorkspacePackage( + cwd, + specifier.packageName + ) + + if (!workspacePackage) { + return null + } + + const entries = getWorkspacePackageExportEntries(workspacePackage) + + const exactMatch = entries.find( + (entry) => !entry.hasWildcard && entry.aliasBase === importPath + ) + + if (exactMatch) { + return { + path: path.resolve(exactMatch.packageRoot, exactMatch.target), + matchedAlias: exactMatch.aliasBase, + matchedTarget: exactMatch.target, + emitMode: exactMatch.emitMode, + } + } + + const wildcardMatches = entries + .filter((entry) => entry.hasWildcard) + .sort((a, b) => b.aliasBase.length - a.aliasBase.length) + + for (const entry of wildcardMatches) { + const pattern = `${entry.aliasBase}/*` + const [prefix, suffix = ""] = pattern.split("*") + const wildcardValue = getWildcardValue(importPath, prefix, suffix) + + if (wildcardValue === null) { + continue + } + + return { + path: path.resolve( + entry.packageRoot, + applyWildcardTarget(entry.target, wildcardValue) + ), + matchedAlias: pattern, + matchedTarget: entry.target, + emitMode: entry.emitMode, + } + } + + return null +} + +function getWorkspacePackageExportEntries( + workspacePackage: WorkspacePackageInfo +): WorkspacePackageExportEntry[] { + 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 = resolveLocalExportTarget(value) + + if (!target) { + continue + } + + entries.push({ + key, + aliasBase: getAliasBase(workspacePackage.packageName, key), + target, + emitMode: getExportEmitMode(target), + hasWildcard: key.includes("*"), + packageRoot: workspacePackage.packageRoot, + }) + } + + workspaceExportEntriesCache.set(cacheKey, entries) + return entries +} + +async function findWorkspacePackage( + cwd: string, + packageName: string +): Promise { + 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) { + let current = path.resolve(cwd) + + while (true) { + const patterns = await getWorkspacePatterns(current) + + if (patterns.length) { + 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 +} + +function resolveLocalExportTarget(target: unknown): string | null { + if (typeof target === "string") { + return target.startsWith(".") ? target : null + } + + if (Array.isArray(target)) { + for (const value of target) { + const resolved = resolveLocalExportTarget(value) + + if (resolved) { + return resolved + } + } + + return null + } + + if (target && typeof target === "object") { + for (const value of Object.values(target as Record)) { + const resolved = resolveLocalExportTarget(value) + + if (resolved) { + return resolved + } + } + } + + return null +} + +function getExportEmitMode(target: string): ImportEmitMode { + if (!target.includes("*")) { + return "strip_extension" + } + + const suffix = target.slice(target.indexOf("*") + 1) + + if (!suffix) { + return "preserve_extension" + } + + return /^\.[^/]+$/.test(suffix) ? "strip_extension" : "preserve_extension" +} + +function getWildcardValue( + importPath: string, + prefix: string, + suffix: string +): string | null { + if (importPath.startsWith(prefix) && importPath.endsWith(suffix)) { + return suffix + ? importPath.slice(prefix.length, -suffix.length) + : importPath.slice(prefix.length) + } + + if ( + suffix === "" && + prefix.endsWith("/") && + importPath === prefix.slice(0, -1) + ) { + return "" + } + + return null +} + +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/test/commands/init.test.ts b/packages/shadcn/test/commands/init.test.ts new file mode 100644 index 0000000000..3048519ba3 --- /dev/null +++ b/packages/shadcn/test/commands/init.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest" + +import { + getInitAliasDefaults, + shouldRunTemplatePostInit, +} from "../../src/commands/init" + +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: "#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: "#utils", + }) + ).toEqual({ + ui: "#components/ui", + lib: "#lib", + hooks: "#hooks", + utils: "#utils", + }) + }) +}) + +describe("shouldRunTemplatePostInit", () => { + test("does not run post-init for existing projects with an explicit template", () => { + expect( + shouldRunTemplatePostInit( + { postInit: async () => {} }, + false + ) + ).toBe(false) + }) + + test("runs post-init for newly scaffolded template projects", () => { + expect( + shouldRunTemplatePostInit( + { postInit: async () => {} }, + true + ) + ).toBe(true) + }) + + test("does not run post-init when there is no template", () => { + expect(shouldRunTemplatePostInit(undefined, true)).toBe(false) + }) +}) 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/with-package-imports/package.json b/packages/shadcn/test/fixtures/with-package-imports/package.json new file mode 100644 index 0000000000..6a1f332014 --- /dev/null +++ b/packages/shadcn/test/fixtures/with-package-imports/package.json @@ -0,0 +1,14 @@ +{ + "name": "with-package-imports", + "type": "module", + "imports": { + "#components/*": "./src/components/*", + "#components-ext/*": "./src/components/*.tsx", + "#hooks": "./src/hooks/index.ts", + "#utils": "./src/lib/utils.ts", + "#dep": { + "node": "dep-node-native", + "default": "./dep-polyfill.js" + } + } +} diff --git a/packages/shadcn/test/preflights/preflight-init.test.ts b/packages/shadcn/test/preflights/preflight-init.test.ts new file mode 100644 index 0000000000..83dc64b14e --- /dev/null +++ b/packages/shadcn/test/preflights/preflight-init.test.ts @@ -0,0 +1,133 @@ +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, + }, +})) + +import { preFlightInit } from "../../src/preflights/preflight-init" + +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", + force: false, + monorepo: false, + silent: 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( + "No import alias found in your tsconfig.json or package.json#imports configuration." + ) + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Add an alias in compilerOptions.paths or "imports" and try again.' + ) + + exitSpy.mockRestore() + }) +}) 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/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..57f997338c 100644 --- a/packages/shadcn/test/utils/get-config.test.ts +++ b/packages/shadcn/test/utils/get-config.test.ts @@ -6,7 +6,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 +38,125 @@ 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 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 +317,220 @@ 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, + }, + }, + }) }) 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..377d7b320b 100644 --- a/packages/shadcn/test/utils/get-project-info.test.ts +++ b/packages/shadcn/test/utils/get-project-info.test.ts @@ -48,6 +48,48 @@ 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-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/resolve-import.test.ts b/packages/shadcn/test/utils/resolve-import.test.ts index dce1dc9e96..d683222085 100644 --- a/packages/shadcn/test/utils/resolve-import.test.ts +++ b/packages/shadcn/test/utils/resolve-import.test.ts @@ -1,8 +1,11 @@ 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 { + resolveImport, + resolveImportWithMetadata, +} from "../../src/utils/resolve-import" test("resolve import", async () => { expect( @@ -79,3 +82,108 @@ test("resolve import without base url", async () => { path.resolve(cwd, "foo/bar") ) }) + +describe("resolve package imports", () => { + const cwd = path.resolve(__dirname, "../fixtures/with-package-imports") + const config = { + absoluteBaseUrl: cwd, + paths: {}, + cwd, + } + + test("resolves wildcard imports that preserve extensions", async () => { + const result = await resolveImportWithMetadata("#components/button.tsx", config) + + expect(result).toEqual({ + path: path.resolve(cwd, "src/components/button.tsx"), + source: "package_imports", + matchedAlias: "#components/*", + matchedTarget: "./src/components/*", + emitMode: "preserve_extension", + }) + }) + + test("resolves wildcard imports that strip extensions", async () => { + const result = await resolveImportWithMetadata("#components-ext/button", config) + + expect(result).toEqual({ + path: path.resolve(cwd, "src/components/button.tsx"), + source: "package_imports", + matchedAlias: "#components-ext/*", + matchedTarget: "./src/components/*.tsx", + emitMode: "strip_extension", + }) + }) + + test("resolves the root alias for wildcard package imports", async () => { + expect(await resolveImport("#components", config)).toEqual( + path.resolve(cwd, "src/components") + ) + }) + + test("resolves exact imports and prefers local conditional targets", async () => { + expect(await resolveImport("#hooks", config)).toEqual( + path.resolve(cwd, "src/hooks/index.ts") + ) + + expect(await resolveImport("#dep", config)).toEqual( + path.resolve(cwd, "dep-polyfill.js") + ) + }) + + test("falls back to tsconfig paths when package imports do not match", async () => { + expect( + await resolveImportWithMetadata("#/components/ui", { + absoluteBaseUrl: "/Users/shadcn/Projects/foobar", + cwd, + paths: { + "#/*": ["./src/*"], + }, + }) + ).toEqual({ + path: "/Users/shadcn/Projects/foobar/src/components/ui", + source: "tsconfig_paths", + matchedAlias: "#/*", + matchedTarget: "./src/components/ui", + emitMode: "strip_extension", + }) + }) +}) + +describe("resolve workspace package exports", () => { + const root = path.resolve( + __dirname, + "../fixtures/frameworks/vite-monorepo-imports" + ) + const cwd = path.resolve(root, "apps/web") + const config = { + absoluteBaseUrl: cwd, + paths: {}, + cwd, + } + + test("resolves workspace package wildcard exports for file imports", async () => { + const result = await resolveImportWithMetadata( + "@workspace/ui/components/button", + config + ) + + expect(result).toEqual({ + path: path.resolve(root, "packages/ui/src/components/button.tsx"), + source: "workspace_package_exports", + matchedAlias: "@workspace/ui/components/*", + matchedTarget: "./src/components/*.tsx", + emitMode: "strip_extension", + }) + }) + + test("resolves bare alias roots from workspace package wildcard exports", async () => { + expect(await resolveImport("@workspace/ui/components", config)).toEqual( + path.resolve(root, "packages/ui/src/components") + ) + + expect(await resolveImport("@workspace/ui/lib/utils", config)).toEqual( + path.resolve(root, "packages/ui/src/lib/utils.ts") + ) + }) +}) diff --git a/packages/shadcn/test/utils/transform-import.test.ts b/packages/shadcn/test/utils/transform-import.test.ts index 23efb64b33..674d03e2c2 100644 --- a/packages/shadcn/test/utils/transform-import.test.ts +++ b/packages/shadcn/test/utils/transform-import.test.ts @@ -176,6 +176,30 @@ 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 for monorepo", async () => { expect( await transform({ @@ -228,6 +252,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({ diff --git a/packages/shadcn/test/utils/updaters/update-files.test.ts b/packages/shadcn/test/utils/updaters/update-files.test.ts index 8f355f9c7a..859bdb1956 100644 --- a/packages/shadcn/test/utils/updaters/update-files.test.ts +++ b/packages/shadcn/test/utils/updaters/update-files.test.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from "fs" import path from "path" import { afterAll, afterEach, describe, expect, test, vi } from "vitest" +import prompts from "prompts" import { getConfig } from "../../../src/utils/get-config" import { @@ -1073,6 +1074,298 @@ return
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 mark .env file as created when it doesn't exist", async () => { const config = await getConfig( path.resolve(__dirname, "../../fixtures/vite-with-tailwind") @@ -1099,6 +1392,48 @@ ANOTHER_NEW_KEY=another_value`, expect(result.filesUpdated).not.toContain(".env") }) + test("should rewrite app-local files to workspace utils aliases in monorepos without tsconfig paths", async () => { + const config = await getConfig( + path.resolve( + __dirname, + "../../fixtures/frameworks/vite-monorepo-imports/apps/web" + ) + ) + + if (!config) { + throw new Error("Failed to get monorepo app config") + } + + const result = await updateFiles( + [ + { + path: "registry/components/login-form.tsx", + type: "registry:component", + content: `import { cn } from "@/lib/utils" + +export function LoginForm() { + return
{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 +2303,73 @@ describe("toAliasedImport", () => { } expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home") }) + + test("should preserve extensions for package imports that target bare wildcards", () => { + const filePath = "src/components/ui/button.tsx" + const config = { + resolvedPaths: { + cwd: path.resolve(__dirname, "../../fixtures/config-imports"), + components: path.resolve( + __dirname, + "../../fixtures/config-imports/src/components" + ), + ui: path.resolve( + __dirname, + "../../fixtures/config-imports/src/components/ui" + ), + }, + aliases: { + components: "#components", + ui: "#components/ui", + }, + } + const projectInfo = { + aliasPrefix: "#", + } + + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "#components/ui/button.tsx" + ) + }) + + test("should strip extensions for package imports whose target already includes them", () => { + const filePath = "src/components/button.tsx" + const config = { + resolvedPaths: { + cwd: path.resolve(__dirname, "../../fixtures/with-package-imports"), + components: path.resolve( + __dirname, + "../../fixtures/with-package-imports/src/components" + ), + }, + aliases: { + components: "#components-ext", + }, + } + const projectInfo = { + aliasPrefix: "#", + } + + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "#components-ext/button" + ) + }) + + test("should keep exact package import aliases for index files", () => { + const filePath = "src/hooks/index.ts" + const config = { + resolvedPaths: { + cwd: path.resolve(__dirname, "../../fixtures/config-imports"), + hooks: path.resolve(__dirname, "../../fixtures/config-imports/src/hooks"), + }, + aliases: { + hooks: "#hooks", + }, + } + const projectInfo = { + aliasPrefix: "#", + } + + expect(toAliasedImport(filePath, config, projectInfo)).toBe("#hooks") + }) })