diff --git a/packages/shadcn/src/utils/dry-run.ts b/packages/shadcn/src/utils/dry-run.ts index 20e181aa03..460b884118 100644 --- a/packages/shadcn/src/utils/dry-run.ts +++ b/packages/shadcn/src/utils/dry-run.ts @@ -22,6 +22,7 @@ import { transformCss } from "@/src/utils/updaters/update-css" import { transformCssVars } from "@/src/utils/updaters/update-css-vars" import { findCommonRoot, + getPlannedFilePaths, resolveFilePath, rewriteResolvedImportsInContent, } from "@/src/utils/updaters/update-files" @@ -152,32 +153,10 @@ async function processFiles( 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) + const plannedFilePaths = getPlannedFilePaths(files, config, { + isSrcDir: projectInfo?.isSrcDir, + framework: projectInfo?.framework.name, + }) for (let index = 0; index < files.length; index++) { const file = files[index] diff --git a/packages/shadcn/src/utils/import-entries.ts b/packages/shadcn/src/utils/import-entries.ts new file mode 100644 index 0000000000..aea0fcd067 --- /dev/null +++ b/packages/shadcn/src/utils/import-entries.ts @@ -0,0 +1,152 @@ +import path from "path" + +export type ImportEmitMode = "strip_extension" | "preserve_extension" + +export type ImportResolutionEntry = { + key: string + aliasBase: string + target: string + emitMode: ImportEmitMode + hasWildcard: boolean + rootDir: string +} + +export type ImportResolutionMatch = { + path: string + matchedAlias: string + matchedTarget: string + emitMode: ImportEmitMode +} + +export function resolveLocalPathTarget(target: unknown): string | null { + if (typeof target === "string") { + return target.startsWith(".") ? target : null + } + + if (Array.isArray(target)) { + for (const value of target) { + const resolved = resolveLocalPathTarget(value) + if (resolved) { + return resolved + } + } + + return null + } + + if (target && typeof target === "object") { + for (const value of Object.values(target as Record)) { + const resolved = resolveLocalPathTarget(value) + if (resolved) { + return resolved + } + } + } + + return null +} + +export function getImportTargetEmitMode(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" +} + +export function resolveImportEntryMatch( + importPath: string, + entries: ImportResolutionEntry[] +): ImportResolutionMatch | null { + const exactMatch = entries.find( + (entry) => !entry.hasWildcard && entry.key === importPath + ) + + if (exactMatch) { + return { + path: path.resolve(exactMatch.rootDir, exactMatch.target), + matchedAlias: exactMatch.key, + matchedTarget: exactMatch.target, + emitMode: exactMatch.emitMode, + } + } + + const wildcardMatches = entries + .filter((entry) => entry.hasWildcard) + .sort((a, b) => b.key.length - a.key.length) + + for (const entry of wildcardMatches) { + const wildcardValue = getPatternWildcardValue(importPath, entry.key, { + allowBareAliasBase: true, + }) + + if (wildcardValue === null) { + continue + } + + return { + path: path.resolve( + entry.rootDir, + applyWildcardTarget(entry.target, wildcardValue) + ), + matchedAlias: entry.key, + matchedTarget: entry.target, + emitMode: entry.emitMode, + } + } + + return null +} + +export function getPatternWildcardValue( + importPath: string, + pattern: string, + options: { + allowBareAliasBase?: boolean + } = {} +): string | null { + if (!pattern.includes("*")) { + return importPath === pattern ? "" : null + } + + const [prefix, suffix = ""] = pattern.split("*") + + if (importPath.startsWith(prefix) && importPath.endsWith(suffix)) { + return suffix + ? importPath.slice(prefix.length, -suffix.length) + : importPath.slice(prefix.length) + } + + if ( + options.allowBareAliasBase && + suffix === "" && + prefix.endsWith("/") && + importPath === prefix.slice(0, -1) + ) { + return "" + } + + return null +} + +export function applyWildcardTarget(target: string, wildcardValue: string) { + if (!target.includes("*")) { + return target + } + + const [prefix, suffix = ""] = target.split("*") + + if (!wildcardValue) { + return prefix.replace(/\/$/, "") + } + + return `${prefix}${wildcardValue}${suffix}` +} diff --git a/packages/shadcn/src/utils/package-imports.ts b/packages/shadcn/src/utils/package-imports.ts index 9a97175083..359296c2e8 100644 --- a/packages/shadcn/src/utils/package-imports.ts +++ b/packages/shadcn/src/utils/package-imports.ts @@ -1,22 +1,17 @@ import path from "path" import { getPackageInfo } from "@/src/utils/get-package-info" +import { + getImportTargetEmitMode, + resolveImportEntryMatch, + resolveLocalPathTarget, + type ImportEmitMode, + type ImportResolutionEntry, + type ImportResolutionMatch, +} from "@/src/utils/import-entries" -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 -} +export type { ImportEmitMode } from "@/src/utils/import-entries" +export type PackageImportEntry = ImportResolutionEntry +export type PackageImportMatch = ImportResolutionMatch const packageImportEntriesCache = new Map() @@ -43,7 +38,7 @@ export function getPackageImportEntries(cwd: string): PackageImportEntry[] { continue } - const target = resolveLocalImportTarget(value) + const target = resolveLocalPathTarget(value) if (!target) { continue } @@ -52,8 +47,9 @@ export function getPackageImportEntries(cwd: string): PackageImportEntry[] { key, aliasBase: key.endsWith("/*") ? key.slice(0, -2) : key, target, - emitMode: getImportEmitMode(target), + emitMode: getImportTargetEmitMode(target), hasWildcard: key.includes("*"), + rootDir: cacheKey, }) } @@ -75,41 +71,7 @@ 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 + return resolveImportEntryMatch(importPath, getPackageImportEntries(cwd)) } export function getPackageImportAliases(cwd: string) { @@ -124,89 +86,6 @@ export function getPackageImportAliases(cwd: string) { } } -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" diff --git a/packages/shadcn/src/utils/resolve-import.ts b/packages/shadcn/src/utils/resolve-import.ts index 25acf89429..dc5eb183fc 100644 --- a/packages/shadcn/src/utils/resolve-import.ts +++ b/packages/shadcn/src/utils/resolve-import.ts @@ -26,59 +26,19 @@ export async function resolveImportWithMetadata( ): Promise { const cwd = config.cwd ?? config.absoluteBaseUrl - if (importPath.startsWith("#")) { - const packageImport = resolvePackageImport(importPath, cwd) + for (const resolver of [ + () => resolveFromPackageImports(importPath, cwd), + () => resolveFromWorkspacePackageExports(importPath, cwd), + () => resolveFromTsconfigPaths(importPath, config), + ]) { + const resolved = await resolver() - if (packageImport) { - return { - path: packageImport.path, - source: "package_imports", - matchedAlias: packageImport.matchedAlias, - matchedTarget: packageImport.matchedTarget, - emitMode: packageImport.emitMode, - } + if (resolved) { + return resolved } } - 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", - } + return null } export async function resolveImport( @@ -109,6 +69,79 @@ function isScopedPackageSpecifier(importPath: string) { return /^@[^/]+\/[^/]+(?:\/.*)?$/.test(importPath) } +function toResolvedImport( + source: ResolvedImport["source"], + resolved: { + path: string + matchedAlias: string + matchedTarget: string + emitMode: ImportEmitMode + } | null +) { + if (!resolved) { + return null + } + + return { + path: resolved.path, + source, + matchedAlias: resolved.matchedAlias, + matchedTarget: resolved.matchedTarget, + emitMode: resolved.emitMode, + } satisfies ResolvedImport +} + +function resolveFromPackageImports(importPath: string, cwd: string) { + if (!importPath.startsWith("#")) { + return null + } + + return toResolvedImport( + "package_imports", + resolvePackageImport(importPath, cwd) + ) +} + +async function resolveFromWorkspacePackageExports( + importPath: string, + cwd: string +) { + return toResolvedImport( + "workspace_package_exports", + await resolveWorkspacePackageExport(importPath, cwd) + ) +} + +function resolveFromTsconfigPaths( + importPath: string, + config: ResolveImportConfig +): ResolvedImport | null { + const matchedPath = createMatchPath(config.absoluteBaseUrl, config.paths)( + importPath, + undefined, + () => true, + [".ts", ".tsx", ".jsx", ".js", ".css"] + ) + + if (!matchedPath) { + return null + } + + const matchedPattern = findMatchingTsPathPattern(importPath, config.paths) + + if (!matchedPattern && isScopedPackageSpecifier(importPath)) { + return null + } + + return { + path: matchedPath, + source: "tsconfig_paths", + matchedAlias: matchedPattern?.key ?? importPath, + matchedTarget: matchedPattern?.target ?? matchedPath, + emitMode: "strip_extension", + } +} + function findMatchingTsPathPattern( importPath: string, paths: ConfigLoaderSuccessResult["paths"] diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index dfff3522ee..7ca9771457 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -34,7 +34,7 @@ import { transformRtl } from "@/src/utils/transformers/transform-rtl" import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix" import prompts from "prompts" import { Project, ScriptKind } from "ts-morph" -import { loadConfig } from "tsconfig-paths" +import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths" import { z } from "zod" export async function updateFiles( @@ -594,7 +594,7 @@ async function resolveImports(filePaths: string[], config: Config) { return updatedFiles } -function getPlannedFilePaths( +export function getPlannedFilePaths( files: RegistryItem["files"], config: Config, options: { @@ -683,26 +683,11 @@ export async function rewriteResolvedImportsInContent({ 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!, + const resolvedImportFilePath = await resolveImportFilePathForRewrite( + moduleSpecifier, filePaths, - config + config, + tsConfig ) if (!resolvedImportFilePath) { @@ -727,6 +712,35 @@ export async function rewriteResolvedImportsInContent({ return hasChanges ? workingSourceFile.getFullText() : content } +async function resolveImportFilePathForRewrite( + moduleSpecifier: string, + filePaths: string[], + config: Config, + tsConfig: Pick +) { + const probableImportFilePath = ( + await resolveImportWithMetadata(moduleSpecifier, { + ...tsConfig, + cwd: config.resolvedPaths.cwd, + }) + )?.path + + const fallbackImportFilePath = + !probableImportFilePath && !moduleSpecifier.startsWith(".") + ? resolveImportFromConfiguredAliases(moduleSpecifier, config) + : null + + if (!probableImportFilePath && !fallbackImportFilePath) { + return null + } + + return resolveModuleByProbablePath( + probableImportFilePath ?? fallbackImportFilePath!, + filePaths, + config + ) +} + /** * Given an absolute "probable" import path (no ext), * plus an array of absolute file paths you already know about, diff --git a/packages/shadcn/src/utils/workspace-package-exports.ts b/packages/shadcn/src/utils/workspace-package-exports.ts index e02465cf00..0fcf32c1f7 100644 --- a/packages/shadcn/src/utils/workspace-package-exports.ts +++ b/packages/shadcn/src/utils/workspace-package-exports.ts @@ -1,30 +1,24 @@ 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" import fs from "fs-extra" +import { + getImportTargetEmitMode, + resolveImportEntryMatch, + resolveLocalPathTarget, + type ImportEmitMode, + type ImportResolutionEntry, + type ImportResolutionMatch, +} from "@/src/utils/import-entries" 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 -} +type WorkspacePackageExportEntry = ImportResolutionEntry +export type WorkspacePackageExportMatch = ImportResolutionMatch const workspacePackageCache = new Map< string, @@ -55,46 +49,10 @@ export async function resolveWorkspacePackageExport( return null } - const entries = getWorkspacePackageExportEntries(workspacePackage) - - const exactMatch = entries.find( - (entry) => !entry.hasWildcard && entry.aliasBase === importPath + return resolveImportEntryMatch( + importPath, + getWorkspacePackageExportEntries(workspacePackage) ) - - 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( @@ -126,19 +84,21 @@ function getWorkspacePackageExportEntries( continue } - const target = resolveLocalExportTarget(value) + const target = resolveLocalPathTarget(value) if (!target) { continue } + const aliasBase = getAliasBase(workspacePackage.packageName, key) + entries.push({ - key, - aliasBase: getAliasBase(workspacePackage.packageName, key), + key: key.includes("*") ? `${aliasBase}/*` : aliasBase, + aliasBase, target, - emitMode: getExportEmitMode(target), + emitMode: getImportTargetEmitMode(target), hasWildcard: key.includes("*"), - packageRoot: workspacePackage.packageRoot, + rootDir: workspacePackage.packageRoot, }) } @@ -292,83 +252,3 @@ function getAliasBase(packageName: string, exportKey: string) { 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}` -}