From d0306774fe0ecc1eae9ef1e918bf7862e866a9e8 Mon Sep 17 00:00:00 2001 From: shadcn Date: Sat, 19 Apr 2025 13:31:04 +0400 Subject: [PATCH] feat(shadcn): resolve imports from anywhere (#7220) * feat(shadcn): resolve imports from anywhere * fix: type errors * fix: add debug * feat: handle root paths * fix: src prefix * fix: tests * chore: changeset --- .changeset/funny-coins-remember.md | 5 + .../shadcn/src/utils/updaters/update-files.ts | 248 ++++++++++++- .../test/utils/updaters/update-files.test.ts | 340 ++++++++++++++++++ 3 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 .changeset/funny-coins-remember.md diff --git a/.changeset/funny-coins-remember.md b/.changeset/funny-coins-remember.md new file mode 100644 index 0000000000..5507cb1327 --- /dev/null +++ b/.changeset/funny-coins-remember.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +resolve imports from anywhere diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index 5ad751cbab..5aab4efde2 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -1,4 +1,5 @@ import { existsSync, promises as fs } from "fs" +import { tmpdir } from "os" import path, { basename } from "path" import { getRegistryBaseColor } from "@/src/registry/api" import { RegistryItem, registryItemFileSchema } from "@/src/registry/schema" @@ -6,6 +7,7 @@ import { Config } from "@/src/utils/get-config" import { ProjectInfo, getProjectInfo } 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 { spinner } from "@/src/utils/spinner" import { transform } from "@/src/utils/transformers" import { transformCssVars } from "@/src/utils/transformers/transform-css-vars" @@ -14,6 +16,8 @@ import { transformImport } from "@/src/utils/transformers/transform-import" import { transformRsc } from "@/src/utils/transformers/transform-rsc" 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 { z } from "zod" export async function updateFiles( @@ -50,9 +54,9 @@ export async function updateFiles( getRegistryBaseColor(config.tailwind.baseColor), ]) - const filesCreated = [] - const filesUpdated = [] - const filesSkipped = [] + let filesCreated: string[] = [] + let filesUpdated: string[] = [] + let filesSkipped: string[] = [] for (const file of files) { if (!file.content) { @@ -153,11 +157,25 @@ export async function updateFiles( : filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath)) } + const allFiles = [...filesCreated, ...filesUpdated, ...filesSkipped] + const updatedFiles = await resolveImports(allFiles, config) + + // Let's update filesUpdated with the updated files. + filesUpdated.push(...updatedFiles) + + // If a file is in filesCreated and filesUpdated, we should remove it from filesUpdated. + filesUpdated = filesUpdated.filter((file) => !filesCreated.includes(file)) + const hasUpdatedFiles = filesCreated.length || filesUpdated.length if (!hasUpdatedFiles && !filesSkipped.length) { filesCreatedSpinner?.info("No files updated.") } + // Remove duplicates. + filesCreated = Array.from(new Set(filesCreated)) + filesUpdated = Array.from(new Set(filesUpdated)) + filesSkipped = Array.from(new Set(filesSkipped)) + if (filesCreated.length) { filesCreatedSpinner?.succeed( `Created ${filesCreated.length} ${ @@ -371,3 +389,227 @@ export function resolvePageTarget( return "" } + +async function resolveImports(filePaths: string[], config: Config) { + const project = new Project({ + compilerOptions: {}, + }) + const projectInfo = await getProjectInfo(config.resolvedPaths.cwd) + const tsConfig = await loadConfig(config.resolvedPaths.cwd) + const updatedFiles = [] + + if (!projectInfo || tsConfig.resultType === "failed") { + return [] + } + + for (const filepath of filePaths) { + const resolvedPath = path.resolve(config.resolvedPaths.cwd, filepath) + + // Check if the file exists. + if (!existsSync(resolvedPath)) { + continue + } + + const content = await fs.readFile(resolvedPath, "utf-8") + + const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-")) + const sourceFile = project.createSourceFile( + path.join(dir, basename(resolvedPath)), + content, + { + scriptKind: ScriptKind.TSX, + } + ) + + 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) + } + } + + return updatedFiles +} + +/** + * Given an absolute "probable" import path (no ext), + * plus an array of absolute file paths you already know about, + * return 0–N matches (best match first), and also check disk for any missing ones. + */ +export function resolveModuleByProbablePath( + probableImportFilePath: string, + files: string[], + config: Config, + extensions: string[] = [".tsx", ".ts", ".js", ".jsx", ".css"] +) { + const cwd = path.normalize(config.resolvedPaths.cwd) + + // 1) Build a set of POSIX-normalized, project-relative files + const relativeFiles = files.map((f) => f.split(path.sep).join(path.posix.sep)) + const fileSet = new Set(relativeFiles) + + // 2) Strip any existing extension off the absolute base path + const extInPath = path.extname(probableImportFilePath) + const hasExt = extInPath !== "" + const absBase = hasExt + ? probableImportFilePath.slice(0, -extInPath.length) + : probableImportFilePath + + // 3) Compute the project-relative "base" directory for strong matching + const relBaseRaw = path.relative(cwd, absBase) + const relBase = relBaseRaw.split(path.sep).join(path.posix.sep) + + // 4) Decide which extensions to try + const tryExts = hasExt ? [extInPath] : extensions + + // 5) Collect candidates + const candidates = new Set() + + // 5a) Fast‑path: [base + ext] and [base/index + ext] + for (const e of tryExts) { + const absCand = absBase + e + const relCand = path.posix.normalize(path.relative(cwd, absCand)) + if (fileSet.has(relCand) || existsSync(absCand)) { + candidates.add(relCand) + } + + const absIdx = path.join(absBase, `index${e}`) + const relIdx = path.posix.normalize(path.relative(cwd, absIdx)) + if (fileSet.has(relIdx) || existsSync(absIdx)) { + candidates.add(relIdx) + } + } + + // 5b) Fallback: scan known files by basename + const name = path.basename(absBase) + for (const f of relativeFiles) { + if (tryExts.some((e) => f.endsWith(`/${name}${e}`))) { + candidates.add(f) + } + } + + // 6) If no matches, bail + if (candidates.size === 0) return null + + // 7) Sort by (1) extension priority, then (2) "strong" base match + const sorted = Array.from(candidates).sort((a, b) => { + // a) extension order + const aExt = path.posix.extname(a) + const bExt = path.posix.extname(b) + const ord = tryExts.indexOf(aExt) - tryExts.indexOf(bExt) + if (ord !== 0) return ord + // b) strong match if path starts with relBase + const aStrong = relBase && a.startsWith(relBase) ? -1 : 1 + const bStrong = relBase && b.startsWith(relBase) ? -1 : 1 + return aStrong - bStrong + }) + + // 8) Return the first (best) candidate + return sorted[0] +} + +export function toAliasedImport( + filePath: string, + config: Config, + projectInfo: ProjectInfo +): string | null { + const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath)) + + // 1️⃣ Find the longest matching alias root in resolvedPaths + // e.g. key="ui", root="/…/components/ui" beats key="components" + const matches = Object.entries(config.resolvedPaths) + .filter( + ([, root]) => root && abs.startsWith(path.normalize(root + path.sep)) + ) + .sort((a, b) => b[1].length - a[1].length) + + if (matches.length === 0) { + return null + } + const [aliasKey, rootDir] = matches[0] + + // 2️⃣ Compute the path UNDER that root + let rel = path.relative(rootDir, abs) + // force POSIX-style separators + rel = rel.split(path.sep).join("/") // e.g. "button/index.tsx" + + // 3️⃣ Strip code-file extensions, keep others (css, json, etc.) + const ext = path.posix.extname(rel) + const codeExts = [".ts", ".tsx", ".js", ".jsx"] + const keepExt = codeExts.includes(ext) ? "" : ext + let noExt = rel.slice(0, rel.length - ext.length) + + // 4️⃣ Collapse "/index" to its directory + if (noExt.endsWith("/index")) { + noExt = noExt.slice(0, -"/index".length) + } + + // 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}` + + // Rremove /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}` +} diff --git a/packages/shadcn/test/utils/updaters/update-files.test.ts b/packages/shadcn/test/utils/updaters/update-files.test.ts index 3dfbcf554c..e0b65a7c2c 100644 --- a/packages/shadcn/test/utils/updaters/update-files.test.ts +++ b/packages/shadcn/test/utils/updaters/update-files.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from "fs" import path from "path" import { afterAll, afterEach, describe, expect, test, vi } from "vitest" @@ -5,7 +6,9 @@ import { getConfig } from "../../../src/utils/get-config" import { findCommonRoot, resolveFilePath, + resolveModuleByProbablePath, resolveNestedFilePath, + toAliasedImport, updateFiles, } from "../../../src/utils/updaters/update-files" @@ -809,3 +812,340 @@ return
Hello World
`) }) }) + +describe("resolveModuleByProbablePath", () => { + test("should resolve exact file match in provided files list", () => { + const files = [ + "components/button.tsx", + "components/card.tsx", + "lib/utils.ts", + ] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath("/foo/bar/components/button", files, config) + ).toBe("components/button.tsx") + }) + + test("should resolve index file", () => { + const files = ["components/button/index.tsx", "components/card.tsx"] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath("/foo/bar/components/button", files, config) + ).toBe("components/button/index.tsx") + }) + + test("should try different extensions", () => { + const files = ["components/button.jsx", "components/card.tsx"] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath("/foo/bar/components/button", files, config) + ).toBe("components/button.jsx") + }) + + test("should fallback to basename matching", () => { + const files = ["components/ui/button.tsx", "components/card.tsx"] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath("/foo/bar/components/button", files, config) + ).toBe("components/ui/button.tsx") + }) + + test("should return null when file not found", () => { + const files = ["components/card.tsx", "lib/utils.ts"] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath("/foo/bar/components/button", files, config) + ).toBeNull() + }) + + test("should sort by extension priority", () => { + const files = [ + "components/button.jsx", + "components/button.tsx", + "components/button.js", + ] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath("/foo/bar/components/button", files, config, [ + ".tsx", + ".jsx", + ".js", + ]) + ).toBe("components/button.tsx") + }) + + test("should preserve extension if specified in path", () => { + const files = ["components/button.tsx", "components/button.css"] + const config = { + resolvedPaths: { + cwd: "/foo/bar", + }, + } + expect( + resolveModuleByProbablePath( + "/foo/bar/components/button.css", + files, + config + ) + ).toBe("components/button.css") + }) +}) + +describe("toAliasedImport", () => { + test("should convert components path to aliased import", () => { + const filePath = "components/button.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "@/components/button" + ) + }) + + test("should convert ui path to aliased import", () => { + const filePath = "components/ui/button.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "@/components/ui/button" + ) + }) + + test("should collapse index files", () => { + const filePath = "components/ui/button/index.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "@/components/ui/button" + ) + }) + + test("should return null when no matching alias found", () => { + const filePath = "src/pages/index.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages") + }) + + test("should handle nested directories", () => { + const filePath = "components/forms/inputs/text-input.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "@/components/forms/inputs/text-input" + ) + }) + + test("should keep non-code file extensions", () => { + const filePath = "components/styles/theme.css" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "@/components/styles/theme.css" + ) + }) + + test("should prefer longer matching paths", () => { + const filePath = "components/ui/button.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + }, + aliases: { + components: "@/components", + ui: "@/ui", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/ui/button") + }) + + test("should support tilde (~) alias prefix", () => { + const filePath = "components/button.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + }, + aliases: { + components: "~components", + }, + } + const projectInfo = { + aliasPrefix: "~", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "~components/button" + ) + }) + + test("should support @shadcn alias prefix", () => { + const filePath = "components/ui/button.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + }, + aliases: { + components: "@shadcn/components", + ui: "@shadcn/ui", + }, + } + const projectInfo = { + aliasPrefix: "@shadcn", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe( + "@shadcn/ui/button" + ) + }) + + test("should support ~cn alias prefix", () => { + const filePath = "lib/utils/index.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + lib: "/foo/bar/lib", + }, + aliases: { + lib: "~cn/lib", + }, + } + const projectInfo = { + aliasPrefix: "~cn", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe("~cn/lib/utils") + }) + + test("should use project alias prefix when aliasKey is cwd", () => { + const filePath = "src/pages/home.tsx" + const config = { + resolvedPaths: { + cwd: "/foo/bar", + components: "/foo/bar/components", + ui: "/foo/bar/components/ui", + lib: "/foo/bar/lib", + }, + aliases: { + components: "@/components", + ui: "@/components/ui", + lib: "@/lib", + }, + } + const projectInfo = { + aliasPrefix: "@", + } + expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home") + }) +})