feat: add support for package imports (#10519)

* feat: add support for package imports

* fix

* test(cli): surface add command failures

* test(cli): remove stale pnpm pin from fixture

* fix(cli): reject invalid package import targets

* fix(cli): address package import review feedback

* test: expand coverage

* docs: add package imports docs
This commit is contained in:
shadcn
2026-05-05 12:24:21 +04:00
committed by GitHub
parent 3977fb9ace
commit eb42ae25fd
93 changed files with 5290 additions and 180 deletions

View File

@@ -21,6 +21,7 @@ import {
templates,
} from "@/src/templates/index"
import { addComponents } from "@/src/utils/add-components"
import { getInitAliasDefaults } from "@/src/utils/alias"
import { createProject } from "@/src/utils/create-project"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
@@ -588,6 +589,7 @@ export async function runInit(
}
) {
let projectInfo
let projectConfig
let newProjectTemplate: keyof typeof templates | undefined
// Resolve the effective template if --monorepo is set.
@@ -629,7 +631,7 @@ export async function runInit(
projectInfo = await getProjectInfo(options.cwd)
}
const didCreateProject = Boolean(newProjectTemplate)
projectConfig = await getProjectConfig(options.cwd, projectInfo)
// Use the template from project creation if available,
// or fall back to the explicit --template flag.
@@ -644,6 +646,12 @@ export async function runInit(
// Add button component for new template-based projects.
...(selectedTemplate ? ["button"] : []),
]
// Tie postInit to actual project creation in this run (createProject
// sets newProjectTemplate). A caller-provided `options.isNewProject`
// alone should not trigger postInit.
const templatePostInit = newProjectTemplate
? selectedTemplate?.postInit
: undefined
if (selectedTemplate?.init) {
const result = await selectedTemplate.init({
@@ -657,17 +665,15 @@ export async function runInit(
silent: options.silent,
})
// Run postInit only for newly scaffolded projects (e.g. git init).
if (didCreateProject) {
await selectedTemplate.postInit({ projectPath: options.cwd })
if (templatePostInit) {
// Run postInit for newly scaffolded projects (e.g. git init).
await templatePostInit({ projectPath: options.cwd })
}
return result
}
// Standard init path for existing projects.
const projectConfig = await getProjectConfig(options.cwd, projectInfo)
let config = projectConfig
? await promptForMinimalConfig(projectConfig, options)
: await promptForConfig(await getConfig(options.cwd))
@@ -797,9 +803,9 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
// Run postInit for newly scaffolded projects without a custom init (e.g. git init).
if (selectedTemplate && didCreateProject) {
await selectedTemplate.postInit({ projectPath: options.cwd })
// Run postInit only for newly scaffolded projects.
if (templatePostInit) {
await templatePostInit({ projectPath: options.cwd })
}
return fullConfig
@@ -883,12 +889,6 @@ async function promptForConfig(defaultConfig: Config | null = null) {
)}:`,
initial: defaultConfig?.aliases["components"] ?? DEFAULT_COMPONENTS,
},
{
type: "text",
name: "utils",
message: `Configure the import alias for ${highlighter.info("utils")}:`,
initial: defaultConfig?.aliases["utils"] ?? DEFAULT_UTILS,
},
{
type: "toggle",
name: "rsc",
@@ -903,6 +903,16 @@ async function promptForConfig(defaultConfig: Config | null = null) {
process.exit(1)
}
const existingAliases =
defaultConfig && defaultConfig.aliases.components === options.components
? defaultConfig.aliases
: undefined
const aliasDefaults = getInitAliasDefaults(
options.components,
existingAliases
)
return rawConfigSchema.parse({
$schema: "https://ui.shadcn.com/schema.json",
style: options.style,
@@ -916,11 +926,11 @@ async function promptForConfig(defaultConfig: Config | null = null) {
rsc: options.rsc,
tsx: options.typescript,
aliases: {
utils: options.utils,
components: options.components,
// TODO: fix this.
lib: options.components.replace(/\/components$/, "lib"),
hooks: options.components.replace(/\/components$/, "hooks"),
ui: aliasDefaults.ui,
lib: aliasDefaults.lib,
hooks: aliasDefaults.hooks,
utils: aliasDefaults.utils,
},
})
}

View File

@@ -0,0 +1,140 @@
import { preFlightInit } from "@/src/preflights/preflight-init"
import { afterEach, describe, expect, test, vi } from "vitest"
import { z } from "zod"
const { mockedGetProjectInfo, mockedExistsSync, mockedLogger } = vi.hoisted(
() => ({
mockedGetProjectInfo: vi.fn(),
mockedExistsSync: vi.fn(),
mockedLogger: {
break: vi.fn(),
error: vi.fn(),
},
})
)
vi.mock("@/src/commands/init", () => ({
initOptionsSchema: z.object({
cwd: z.string(),
force: z.boolean(),
monorepo: z.boolean().optional(),
silent: z.boolean().optional(),
existingConfig: z.record(z.unknown()).optional(),
}),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectInfo: mockedGetProjectInfo,
}))
vi.mock("@/src/utils/get-monorepo-info", () => ({
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn().mockResolvedValue([]),
isMonorepoRoot: vi.fn().mockResolvedValue(false),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
info: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: mockedLogger,
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn().mockReturnValue({
start: vi.fn().mockReturnValue({
succeed: vi.fn(),
fail: vi.fn(),
stop: vi.fn(),
}),
}),
}))
vi.mock("fs-extra", () => ({
default: {
existsSync: mockedExistsSync,
},
}))
const baseProjectInfo = {
framework: {
name: "next-app",
label: "Next.js",
links: {
installation: "https://ui.shadcn.com/docs/installation",
tailwind: "https://tailwindcss.com/docs/installation",
},
},
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "app/globals.css",
tailwindVersion: "v4" as const,
frameworkVersion: null,
aliasPrefix: "#",
}
const baseOptions = {
cwd: "/tmp/project",
cssVariables: true,
defaults: false,
force: false,
installStyleIndex: true,
isNewProject: false,
monorepo: false,
silent: true,
yes: true,
}
afterEach(() => {
vi.clearAllMocks()
})
describe("preFlightInit", () => {
test("accepts package import aliases detected from package.json#imports", async () => {
mockedExistsSync.mockImplementation((filePath: string) => {
return !filePath.endsWith("components.json")
})
mockedGetProjectInfo.mockResolvedValue(baseProjectInfo)
const result = await preFlightInit(baseOptions)
expect(result.errors).toEqual({})
expect(result.projectInfo?.aliasPrefix).toBe("#")
expect(mockedLogger.error).not.toHaveBeenCalled()
})
test("reports missing aliases for tsconfig paths and package imports", async () => {
mockedExistsSync.mockImplementation((filePath: string) => {
return !filePath.endsWith("components.json")
})
mockedGetProjectInfo.mockResolvedValue({
...baseProjectInfo,
aliasPrefix: null,
})
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
code?: string | number | null
) => {
throw new Error(`process.exit:${code ?? ""}`)
}) as never)
await expect(preFlightInit(baseOptions)).rejects.toThrow("process.exit:1")
expect(mockedLogger.error).toHaveBeenCalledWith(
"Could not find valid path aliases or package imports for init."
)
expect(mockedLogger.error).toHaveBeenCalledWith(
"Configure path aliases in tsconfig.json or imports in package.json, then run init again."
)
expect(mockedLogger.error).toHaveBeenCalledWith(
"Learn more at https://ui.shadcn.com/docs/installation/manual#configure-import-aliases."
)
exitSpy.mockRestore()
})
})

View File

@@ -1,5 +1,6 @@
import path from "path"
import { initOptionsSchema } from "@/src/commands/init"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import {
formatMonorepoMessage,
@@ -132,6 +133,7 @@ export async function preFlightInit(
const tsConfigSpinner = spinner(`Validating import alias.`, {
silent: options.silent,
}).start()
if (!projectInfo?.aliasPrefix) {
errors[ERRORS.IMPORT_ALIAS_MISSING] = true
tsConfigSpinner?.fail()
@@ -162,14 +164,23 @@ export async function preFlightInit(
if (errors[ERRORS.IMPORT_ALIAS_MISSING]) {
logger.break()
logger.error(`No import alias found in your tsconfig.json file.`)
if (projectInfo?.framework.links.installation) {
logger.error(
`Visit ${highlighter.info(
projectInfo?.framework.links.installation
)} to learn how to set an import alias.`
)
}
logger.error(
`Could not find valid path aliases or package imports for ${highlighter.info(
"init"
)}.`
)
logger.error(
`Configure path aliases in ${highlighter.info(
"tsconfig.json"
)} or imports in ${highlighter.info("package.json")}, then run ${highlighter.info(
"init"
)} again.`
)
logger.error(
`Learn more at ${highlighter.info(
`${SHADCN_URL}/docs/installation/manual#configure-import-aliases`
)}.`
)
}
logger.break()

View File

@@ -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(

View File

@@ -270,16 +270,26 @@ async function addWorkspaceComponents(
"registry:hook": "hooks",
"registry:lib": "lib",
}
const getTargetConfigForType = (type: string) => {
const configKey = FILE_TYPE_TO_CONFIG_KEY[type]
return configKey && workspaceConfig[configKey]
? workspaceConfig[configKey]
: config
}
// Process each type of component with its appropriate target config.
for (const type of Array.from(filesByType.keys())) {
const typeFiles = filesByType.get(type)!
const targetConfig = getTargetConfigForType(type)
const plannedFiles = (tree.files ?? []).filter((file) => {
const fileTargetConfig = getTargetConfigForType(
file.type || "registry:ui"
)
const configKey = FILE_TYPE_TO_CONFIG_KEY[type]
const targetConfig =
configKey && workspaceConfig[configKey]
? workspaceConfig[configKey]
: config
return (
fileTargetConfig.resolvedPaths.cwd === targetConfig.resolvedPaths.cwd
)
})
const typeWorkspaceRoot = findCommonRoot(
config.resolvedPaths.cwd,
@@ -299,6 +309,7 @@ async function addWorkspaceComponents(
isRemote: options.isRemote,
isWorkspace: true,
path: options.path,
plannedFiles,
supportedFontMarkers,
})

View File

@@ -0,0 +1,77 @@
import {
deriveAliasFromComponents,
getInitAliasDefaults,
} from "@/src/utils/alias"
import { describe, expect, test } from "vitest"
describe("deriveAliasFromComponents", () => {
test("derives ui aliases from components", () => {
expect(deriveAliasFromComponents("@/components", "ui")).toBe(
"@/components/ui"
)
})
test("derives utils aliases from lib aliases", () => {
expect(deriveAliasFromComponents("#components", "utils")).toBe("#lib/utils")
expect(
deriveAliasFromComponents("#custom/components", "utils", "#custom/lib")
).toBe("#custom/lib/utils")
})
test("derives sibling lib and hooks aliases from components", () => {
expect(deriveAliasFromComponents("@/components", "lib")).toBe("@/lib")
expect(deriveAliasFromComponents("#custom/components", "hooks")).toBe(
"#custom/hooks"
)
})
test("returns an empty string when components alias has no sibling base", () => {
expect(deriveAliasFromComponents("#custom/ui", "lib")).toBe("")
})
})
describe("getInitAliasDefaults", () => {
test("derives standard aliases from components", () => {
expect(getInitAliasDefaults("@/components")).toEqual({
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
utils: "@/lib/utils",
})
})
test("derives package import aliases from #components", () => {
expect(getInitAliasDefaults("#components")).toEqual({
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
})
})
test("derives sibling aliases for nested custom aliases", () => {
expect(getInitAliasDefaults("#custom/components")).toEqual({
ui: "#custom/components/ui",
lib: "#custom/lib",
hooks: "#custom/hooks",
utils: "#custom/lib/utils",
})
})
test("preserves existing aliases when components alias is unchanged", () => {
expect(
getInitAliasDefaults("#components", {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
})
).toEqual({
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
})
})
})

View File

@@ -0,0 +1,62 @@
import type { Config } from "@/src/utils/get-config"
import { DEFAULT_COMPONENTS, DEFAULT_UTILS } from "@/src/utils/get-config"
export function getInitAliasDefaults(
componentsAlias: string,
existingAliases?: Config["aliases"]
) {
// `lib` is the anchor for deriving `utils`, so reuse the existing value first
// when init is re-running against the same components alias.
const derivedLib =
existingAliases?.lib ?? deriveAliasFromComponents(componentsAlias, "lib")
return {
ui: existingAliases?.ui ?? deriveAliasFromComponents(componentsAlias, "ui"),
lib: derivedLib,
hooks:
existingAliases?.hooks ??
deriveAliasFromComponents(componentsAlias, "hooks"),
utils:
existingAliases?.utils ??
deriveAliasFromComponents(componentsAlias, "utils", derivedLib),
}
}
export function deriveAliasFromComponents(
componentsAlias: string,
kind: "ui" | "lib" | "hooks" | "utils",
libAlias?: string
) {
const alias = componentsAlias || DEFAULT_COMPONENTS
if (kind === "ui") {
return `${alias}/ui`
}
if (kind === "utils") {
// `utils` follows `lib`, not `components`, so derive or reuse the sibling
// lib alias before appending `/utils`.
const resolvedLib = libAlias || replaceComponentsAliasTail(alias, "lib")
return resolvedLib ? `${resolvedLib}/utils` : DEFAULT_UTILS
}
return replaceComponentsAliasTail(alias, kind)
}
function replaceComponentsAliasTail(alias: string, kind: "lib" | "hooks") {
// Handles the common `@/components` and `#custom/components` forms by
// swapping the trailing `components` segment for a sibling alias root.
if (alias === "components") {
return kind
}
if (alias.endsWith("/components")) {
return `${alias.slice(0, -"/components".length)}/${kind}`
}
if (alias.endsWith("components") && !alias.includes("/")) {
return `${alias.slice(0, -"components".length)}${kind}`
}
return ""
}

View File

@@ -24,9 +24,13 @@ import { transformCss } from "@/src/utils/updaters/update-css"
import { transformCssVars } from "@/src/utils/updaters/update-css-vars"
import {
findCommonRoot,
getPlannedFilePaths,
resolveFilePath,
rewriteResolvedImportsInContent,
} from "@/src/utils/updaters/update-files"
import { massageTreeForFonts } from "@/src/utils/updaters/update-fonts"
import { Project } from "ts-morph"
import { loadConfig } from "tsconfig-paths"
import type { z } from "zod"
export type DryRunFile = {
@@ -144,6 +148,19 @@ async function processFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
let tsConfig: ReturnType<typeof loadConfig>
try {
tsConfig = loadConfig(config.resolvedPaths.cwd)
} catch {
tsConfig = { resultType: "failed" } as ReturnType<typeof loadConfig>
}
const project = new Project({
compilerOptions: {},
})
const plannedFilePaths = getPlannedFilePaths(files, config, {
isSrcDir: projectInfo?.isSrcDir,
framework: projectInfo?.framework.name,
})
for (let index = 0; index < files.length; index++) {
const file = files[index]
@@ -203,13 +220,25 @@ async function processFiles(
transformCleanup,
]
)
const finalContent =
isEnvFile(filePath) || isUniversalItemFile
? content
: await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project,
projectInfo,
resolvedPath: filePath,
tsConfig,
})
// Determine action.
let action: DryRunFile["action"] = "create"
let oldContent: string | undefined
if (existingFile) {
oldContent = await fs.readFile(filePath, "utf-8")
if (isContentSame(oldContent, content)) {
if (isContentSame(oldContent, finalContent)) {
action = "skip"
} else {
action = "overwrite"
@@ -219,7 +248,7 @@ async function processFiles(
result.files.push({
path: relativePath,
action,
content,
content: finalContent,
...(action === "overwrite" && { existingContent: oldContent }),
type: file.type ?? "registry:ui",
})

View File

@@ -7,10 +7,10 @@ import {
} from "@/src/schema"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { resolveImport } from "@/src/utils/resolve-import"
import { resolveImportWithMetadata } from "@/src/utils/resolve-import"
import { cosmiconfig } from "cosmiconfig"
import fg from "fast-glob"
import { loadConfig } from "tsconfig-paths"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { z } from "zod"
export const DEFAULT_STYLE = "default"
@@ -64,6 +64,37 @@ export async function resolveConfigPaths(
)
}
// Resolve the primary aliases first so fallbacks can reuse their results.
const resolvedUtils = await resolveAliasPath(
"utils",
config.aliases["utils"],
cwd,
tsConfig
)
const resolvedComponents = await resolveAliasPath(
"components",
config.aliases["components"],
cwd,
tsConfig
)
const resolvedUi = config.aliases["ui"]
? await resolveAliasPath("ui", config.aliases["ui"], cwd, tsConfig)
: path.resolve(resolvedComponents ?? cwd, "ui")
const resolvedLib = config.aliases["lib"]
? await resolveAliasPath("lib", config.aliases["lib"], cwd, tsConfig)
: path.resolve(resolvedUtils ?? cwd, "..")
const resolvedHooks = config.aliases["hooks"]
? await resolveAliasPath("hooks", config.aliases["hooks"], cwd, tsConfig)
: path.resolve(resolvedComponents ?? cwd, "..", "hooks")
assertResolvedAliases(cwd, {
components: resolvedComponents,
utils: resolvedUtils,
ui: resolvedUi,
lib: resolvedLib,
hooks: resolvedHooks,
})
return configSchema.parse({
...config,
resolvedPaths: {
@@ -72,35 +103,93 @@ export async function resolveConfigPaths(
? path.resolve(cwd, config.tailwind.config)
: "",
tailwindCss: path.resolve(cwd, config.tailwind.css),
utils: await resolveImport(config.aliases["utils"], tsConfig),
components: await resolveImport(config.aliases["components"], tsConfig),
ui: config.aliases["ui"]
? await resolveImport(config.aliases["ui"], tsConfig)
: path.resolve(
(await resolveImport(config.aliases["components"], tsConfig)) ??
cwd,
"ui"
),
utils: resolvedUtils,
components: resolvedComponents,
ui: resolvedUi,
// TODO: Make this configurable.
// For now, we assume the lib and hooks directories are one level up from the components directory.
lib: config.aliases["lib"]
? await resolveImport(config.aliases["lib"], tsConfig)
: path.resolve(
(await resolveImport(config.aliases["utils"], tsConfig)) ?? cwd,
".."
),
hooks: config.aliases["hooks"]
? await resolveImport(config.aliases["hooks"], tsConfig)
: path.resolve(
(await resolveImport(config.aliases["components"], tsConfig)) ??
cwd,
"..",
"hooks"
),
lib: resolvedLib,
hooks: resolvedHooks,
},
})
}
async function resolveAliasPath(
aliasKey: "components" | "utils" | "ui" | "lib" | "hooks",
alias: string,
cwd: string,
tsConfig: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
const resolved = await resolveImportWithMetadata(alias, {
...tsConfig,
cwd,
})
if (!resolved?.path) {
return null
}
if (alias.startsWith("#") && resolved.path === path.resolve(cwd, alias)) {
return null
}
// For non-utils alias keys backed by package imports or workspace exports,
// strip directory-level artifacts so the resolved path points at the
// directory root rather than a specific file.
if (
aliasKey !== "utils" &&
(resolved.source === "package_imports" ||
resolved.source === "workspace_package_exports")
) {
// Exact aliases (e.g. `#hooks` → `./src/hooks/index.ts`) should resolve
// to the directory root.
if (
!resolved.matchedAlias.includes("*") &&
/\/index\.[^/]+$/.test(resolved.path)
) {
return path.dirname(resolved.path)
}
// Wildcard aliases with explicit extensions (e.g. `#components/*` →
// `./src/components/*.tsx`) should strip the source extension so `ui`
// resolves to `/src/components/ui` instead of `/src/components/ui.tsx`.
if (resolved.matchedAlias.includes("*") && /\.[^/]+$/.test(resolved.path)) {
return resolved.path.replace(/\.[^/]+$/, "")
}
}
return resolved.path
}
function assertResolvedAliases(
cwd: string,
resolvedAliases: Record<
"components" | "utils" | "ui" | "lib" | "hooks",
string | null
>
) {
const missingAliases = ["components", "ui", "lib", "hooks", "utils"].filter(
(key) => !resolvedAliases[key as keyof typeof resolvedAliases]
)
if (!missingAliases.length) {
return
}
throw new Error(
[
`Could not resolve the following aliases in ${highlighter.info(cwd)}: ${highlighter.info(
missingAliases.join(", ")
)}.`,
`Configure path aliases in ${highlighter.info(
"tsconfig.json"
)} or imports in ${highlighter.info(
"package.json"
)} for this workspace and try again.`,
].join("\n")
)
}
export async function getRawConfig(
cwd: string
): Promise<z.infer<typeof rawConfigSchema> | null> {
@@ -158,7 +247,20 @@ export async function getWorkspaceConfig(config: Config) {
continue
}
resolvedAliases[key] = await getConfig(packageRoot)
const workspaceConfig = await getConfig(packageRoot)
if (!workspaceConfig) {
throw new Error(
[
`Could not load the workspace config in ${highlighter.info(packageRoot)}.`,
`Add ${highlighter.info(
"components.json"
)} to this workspace and configure its path aliases or package imports, then try again.`,
].join("\n")
)
}
resolvedAliases[key] = workspaceConfig
}
const result = workspaceConfigSchema.safeParse(resolvedAliases)

View File

@@ -127,7 +127,7 @@ export function formatMonorepoMessage(
logger.break()
}
async function getWorkspacePatterns(cwd: string) {
export async function getWorkspacePatterns(cwd: string) {
const patterns: string[] = []
// Read pnpm-workspace.yaml.

View File

@@ -6,6 +6,10 @@ import { rawConfigSchema } from "@/src/schema"
import { Framework, FRAMEWORKS } from "@/src/utils/frameworks"
import { Config, getConfig, resolveConfigPaths } from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
getPackageImportAliases,
getPackageImportPrefix,
} from "@/src/utils/package-imports"
import fg from "fast-glob"
import fs from "fs-extra"
import { loadConfig } from "tsconfig-paths"
@@ -50,7 +54,7 @@ export async function getProjectInfo(
tailwindConfigFile,
tailwindCssFile,
tailwindVersion,
aliasPrefix,
aliasPrefixInfo,
packageJson,
] = await Promise.all([
fg.glob(
@@ -66,7 +70,7 @@ export async function getProjectInfo(
getTailwindConfigFile(cwd),
getTailwindCssFile(cwd, opts?.configCssFile),
getTailwindVersion(cwd),
getTsConfigAliasPrefix(cwd),
getProjectAliasInfo(cwd),
getPackageInfo(cwd, false),
])
@@ -83,7 +87,7 @@ export async function getProjectInfo(
tailwindCssFile,
tailwindVersion,
frameworkVersion: null,
aliasPrefix,
aliasPrefix: aliasPrefixInfo.prefix,
}
// Next.js.
@@ -300,28 +304,62 @@ export async function getTailwindConfigFile(cwd: string) {
export async function getTsConfigAliasPrefix(cwd: string) {
const tsConfig = await loadConfig(cwd)
const paths =
tsConfig?.resultType === "success" && Object.entries(tsConfig.paths).length
? tsConfig.paths
: (await getTsConfig(cwd))?.compilerOptions.paths
if (
tsConfig?.resultType === "failed" ||
!Object.entries(tsConfig?.paths).length
) {
if (!paths || !Object.entries(paths).length) {
return null
}
// This assume that the first alias is the prefix.
for (const [alias, paths] of Object.entries(tsConfig.paths)) {
for (const [alias, targets] of Object.entries(paths)) {
const values = Array.isArray(targets) ? targets : [targets]
if (
paths.includes("./*") ||
paths.includes("./src/*") ||
paths.includes("./app/*") ||
paths.includes("./resources/js/*") // Laravel.
values.includes("./*") ||
values.includes("./src/*") ||
values.includes("./app/*") ||
values.includes("./resources/js/*") // Laravel.
) {
return alias.replace(/\/\*$/, "") ?? null
}
}
// Use the first alias as the prefix.
return Object.keys(tsConfig?.paths)?.[0].replace(/\/\*$/, "") ?? null
return Object.keys(paths)?.[0].replace(/\/\*$/, "") ?? null
}
export async function getProjectAliasInfo(cwd: string) {
const tsConfigAliasPrefix = await getTsConfigAliasPrefix(cwd)
const packageImportPrefix = getPackageImportPrefix(cwd)
if (packageImportPrefix && tsConfigAliasPrefix?.startsWith("#")) {
return {
prefix: packageImportPrefix,
source: "package_imports" as const,
}
}
if (tsConfigAliasPrefix) {
return {
prefix: tsConfigAliasPrefix,
source: "tsconfig_paths" as const,
}
}
if (packageImportPrefix) {
return {
prefix: packageImportPrefix,
source: "package_imports" as const,
}
}
return {
prefix: null,
source: null,
}
}
export async function isTypeScriptProject(cwd: string) {
@@ -345,10 +383,16 @@ export async function getTsConfig(cwd: string) {
continue
}
// We can't use fs.readJSON because it doesn't support comments.
const contents = await fs.readFile(filePath, "utf8")
const cleanedContents = contents.replace(/\/\*\s*\*\//g, "")
const result = TS_CONFIG_SCHEMA.safeParse(JSON.parse(cleanedContents))
let parsed
try {
parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(contents))
} catch {
continue
}
const result = TS_CONFIG_SCHEMA.safeParse(parsed)
if (result.error) {
continue
@@ -360,16 +404,133 @@ export async function getTsConfig(cwd: string) {
return null
}
function stripJsonCommentsAndTrailingCommas(value: string) {
let result = ""
let inString = false
let escaped = false
for (let index = 0; index < value.length; index++) {
const current = value[index]
const next = value[index + 1]
if (inString) {
result += current
if (escaped) {
escaped = false
continue
}
if (current === "\\") {
escaped = true
continue
}
if (current === '"') {
inString = false
}
continue
}
if (current === '"') {
inString = true
result += current
continue
}
if (current === "/" && next === "/") {
while (index < value.length && value[index] !== "\n") {
index++
}
if (index < value.length) {
result += value[index]
}
continue
}
if (current === "/" && next === "*") {
index += 2
while (index < value.length) {
if (value[index] === "*" && value[index + 1] === "/") {
index++
break
}
if (value[index] === "\n") {
result += "\n"
}
index++
}
continue
}
if (current === ",") {
let nextIndex = index + 1
while (nextIndex < value.length) {
while (/\s/.test(value[nextIndex] ?? "")) {
nextIndex++
}
if (value[nextIndex] === "/" && value[nextIndex + 1] === "/") {
nextIndex += 2
while (
nextIndex < value.length &&
value[nextIndex] !== "\n" &&
value[nextIndex] !== "\r"
) {
nextIndex++
}
continue
}
if (value[nextIndex] === "/" && value[nextIndex + 1] === "*") {
nextIndex += 2
while (
nextIndex < value.length &&
!(value[nextIndex] === "*" && value[nextIndex + 1] === "/")
) {
nextIndex++
}
nextIndex += 2
continue
}
break
}
if (value[nextIndex] === "}" || value[nextIndex] === "]") {
continue
}
}
result += current
}
return result
}
export async function getProjectConfig(
cwd: string,
defaultProjectInfo: ProjectInfo | null = null
): Promise<Config | null> {
// Check for existing component config.
const [existingConfig, projectInfo] = await Promise.all([
const [existingConfig, projectInfo, aliasInfo] = await Promise.all([
getConfig(cwd),
!defaultProjectInfo
? getProjectInfo(cwd)
: Promise.resolve(defaultProjectInfo),
getProjectAliasInfo(cwd),
])
if (existingConfig) {
@@ -384,6 +545,35 @@ export async function getProjectConfig(
return null
}
const packageImportAliases =
aliasInfo.source === "package_imports" ? getPackageImportAliases(cwd) : null
if (!projectInfo.aliasPrefix) {
return null
}
const fallbackAliases = getAliasDefaultsFromPrefix(
projectInfo.aliasPrefix,
aliasInfo.source === "package_imports"
)
const aliases =
aliasInfo.source === "package_imports" && packageImportAliases
? derivePackageImportAliases({
...fallbackAliases,
components:
packageImportAliases.components ?? fallbackAliases.components,
ui: packageImportAliases.ui ?? fallbackAliases.ui,
hooks: packageImportAliases.hooks ?? fallbackAliases.hooks,
lib: packageImportAliases.lib ?? fallbackAliases.lib,
utils: packageImportAliases.utils ?? fallbackAliases.utils,
})
: fallbackAliases
if (!aliases.components || !aliases.utils) {
return null
}
const config: z.infer<typeof rawConfigSchema> = {
$schema: "https://ui.shadcn.com/schema.json",
rsc: projectInfo.isRSC,
@@ -397,18 +587,59 @@ export async function getProjectConfig(
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: `${projectInfo.aliasPrefix}/components`,
ui: `${projectInfo.aliasPrefix}/components/ui`,
hooks: `${projectInfo.aliasPrefix}/hooks`,
lib: `${projectInfo.aliasPrefix}/lib`,
utils: `${projectInfo.aliasPrefix}/lib/utils`,
},
aliases,
}
return await resolveConfigPaths(cwd, config)
}
function getAliasDefaultsFromPrefix(
aliasPrefix: string,
isPackageImport: boolean = false
) {
if (isPackageImport && aliasPrefix === "#") {
return {
components: "",
ui: undefined,
hooks: undefined,
lib: undefined,
utils: "",
}
}
return {
components: `${aliasPrefix}/components`,
ui: `${aliasPrefix}/components/ui`,
hooks: `${aliasPrefix}/hooks`,
lib: `${aliasPrefix}/lib`,
utils: `${aliasPrefix}/lib/utils`,
}
}
function derivePackageImportAliases(aliases: {
components: string
ui?: string
hooks?: string
lib?: string
utils: string
}) {
const derivedAliases = { ...aliases }
if (!derivedAliases.ui && derivedAliases.components) {
derivedAliases.ui = `${derivedAliases.components}/ui`
}
if (!derivedAliases.lib && derivedAliases.utils.endsWith("/utils")) {
derivedAliases.lib = derivedAliases.utils.slice(0, -"/utils".length)
}
if (!derivedAliases.utils && derivedAliases.lib) {
derivedAliases.utils = `${derivedAliases.lib}/utils`
}
return derivedAliases
}
export async function getProjectTailwindVersionFromConfig(config: {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
}): Promise<TailwindVersion> {

View File

@@ -0,0 +1,156 @@
import path from "path"
// Node can resolve `package.json#imports` and `package.json#exports` at
// runtime, but the CLI needs the matched pattern, local filesystem target, and
// emit behavior as data so it can place files and rewrite imports consistently.
// This module is the shared matcher for those normalized entry shapes.
export type ImportEmitMode = "strip_extension" | "preserve_extension"
export type ImportResolutionEntry = {
key: string
aliasBase: string
target: string
emitMode: ImportEmitMode
hasWildcard: boolean
rootDir: string
}
export type ImportResolutionMatch = {
path: string
matchedAlias: string
matchedTarget: string
emitMode: ImportEmitMode
}
export function resolveLocalPathTarget(target: unknown) {
const queue = [target]
while (queue.length) {
const value = queue.shift()
if (typeof value === "string") {
if (value.startsWith("./")) {
return value
}
continue
}
if (Array.isArray(value)) {
queue.unshift(...value)
continue
}
if (value && typeof value === "object") {
queue.unshift(...Object.values(value as Record<string, unknown>))
}
}
return null
}
export function getImportTargetEmitMode(target: string) {
if (!target.includes("*")) {
return "strip_extension"
}
const suffix = target.slice(target.indexOf("*") + 1)
// A bare `*` target like `./src/components/*` expects the emitted specifier
// to include the source extension (`#components/button.tsx`).
if (!suffix) {
return "preserve_extension"
}
return /^\.[^/]+$/.test(suffix) ? "strip_extension" : "preserve_extension"
}
export function resolveImportEntryMatch(
importPath: string,
entries: ImportResolutionEntry[]
) {
const exactMatch = entries.find(
(entry) => !entry.hasWildcard && entry.key === importPath
)
if (exactMatch) {
return {
path: path.resolve(exactMatch.rootDir, exactMatch.target),
matchedAlias: exactMatch.key,
matchedTarget: exactMatch.target,
emitMode: exactMatch.emitMode,
}
}
const wildcardMatches = entries
.filter((entry) => entry.hasWildcard)
.sort((a, b) => b.key.length - a.key.length)
for (const entry of wildcardMatches) {
const wildcardValue = getPatternWildcardValue(importPath, entry.key, {
allowBareAliasBase: true,
})
if (wildcardValue === null) {
continue
}
return {
path: path.resolve(
entry.rootDir,
applyWildcardTarget(entry.target, wildcardValue)
),
matchedAlias: entry.key,
matchedTarget: entry.target,
emitMode: entry.emitMode,
}
}
return null
}
export function getPatternWildcardValue(
importPath: string,
pattern: string,
options: {
allowBareAliasBase?: boolean
} = {}
) {
if (!pattern.includes("*")) {
return importPath === pattern ? "" : null
}
const [prefix, suffix = ""] = pattern.split("*")
if (importPath.startsWith(prefix) && importPath.endsWith(suffix)) {
return suffix
? importPath.slice(prefix.length, -suffix.length)
: importPath.slice(prefix.length)
}
if (
options.allowBareAliasBase &&
suffix === "" &&
prefix.endsWith("/") &&
importPath === prefix.slice(0, -1)
) {
return ""
}
return null
}
export function applyWildcardTarget(target: string, wildcardValue: string) {
if (!target.includes("*")) {
return target
}
const [prefix, suffix = ""] = target.split("*")
if (!wildcardValue) {
return prefix.replace(/\/$/, "")
}
return `${prefix}${wildcardValue}${suffix}`
}

View File

@@ -0,0 +1,185 @@
import path from "path"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
getImportTargetEmitMode,
resolveImportEntryMatch,
resolveLocalPathTarget,
type ImportEmitMode,
type ImportResolutionEntry,
type ImportResolutionMatch,
} from "@/src/utils/import-matcher"
export type { ImportEmitMode } from "@/src/utils/import-matcher"
export type PackageImportEntry = ImportResolutionEntry
export type PackageImportMatch = ImportResolutionMatch
const packageImportEntriesCache = new Map<string, PackageImportEntry[]>()
export function getPackageImportEntries(cwd: string) {
const cacheKey = path.resolve(cwd)
const cachedEntries = packageImportEntriesCache.get(cacheKey)
if (cachedEntries) {
return cachedEntries
}
const packageInfo = getPackageInfo(cwd, false)
const imports = packageInfo?.imports
if (!imports || typeof imports !== "object" || Array.isArray(imports)) {
packageImportEntriesCache.set(cacheKey, [])
return []
}
const entries: PackageImportEntry[] = []
for (const [key, value] of Object.entries(imports)) {
if (!key.startsWith("#")) {
continue
}
const target = resolveLocalPathTarget(value)
if (!target) {
continue
}
entries.push({
key,
aliasBase:
key === "#*" ? "#" : key.endsWith("/*") ? key.slice(0, -2) : key,
target,
emitMode: getImportTargetEmitMode(target),
hasWildcard: key.includes("*"),
rootDir: cacheKey,
})
}
packageImportEntriesCache.set(cacheKey, entries)
return entries
}
export function getPackageImportPrefix(cwd: string) {
const aliases = getPackageImportEntries(cwd).map((entry) => entry.aliasBase)
if (!aliases.length) {
return null
}
return getSharedPackageImportPrefix(aliases)
}
export function resolvePackageImport(importPath: string, cwd: string) {
return resolveImportEntryMatch(importPath, getPackageImportEntries(cwd))
}
export function getPackageImportAliases(cwd: string) {
const entries = getPackageImportEntries(cwd)
const rootWildcardDefaults = entries.some((entry) => entry.key === "#*")
? {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
}
: null
return {
components:
findBestAlias(entries, "components") ?? rootWildcardDefaults?.components,
ui: findBestAlias(entries, "ui") ?? rootWildcardDefaults?.ui,
lib: findBestAlias(entries, "lib") ?? rootWildcardDefaults?.lib,
hooks: findBestAlias(entries, "hooks") ?? rootWildcardDefaults?.hooks,
utils: findBestAlias(entries, "utils") ?? rootWildcardDefaults?.utils,
}
}
function findBestAlias(
entries: PackageImportEntry[],
kind: "components" | "ui" | "lib" | "hooks" | "utils"
) {
const matches = entries
.map((entry) => ({
entry,
score: getAliasScore(entry, kind),
}))
.filter((match) => match.score > 0)
.sort(
(a, b) =>
b.score - a.score || b.entry.aliasBase.length - a.entry.aliasBase.length
)
return matches[0]?.entry.aliasBase
}
function getAliasScore(
entry: PackageImportEntry,
kind: "components" | "ui" | "lib" | "hooks" | "utils"
) {
const aliasBase = entry.aliasBase.toLowerCase()
const normalizedTarget = normalizeTarget(entry.target).toLowerCase()
switch (kind) {
case "components":
if (
aliasBase.endsWith("/ui") ||
normalizedTarget.includes("/components/ui")
) {
return 0
}
if (includesPathSegment(aliasBase, "components")) return 4
if (includesPathSegment(normalizedTarget, "components")) return 3
return 0
case "ui":
if (aliasBase.endsWith("/ui") || aliasBase === "#ui") return 5
if (normalizedTarget.includes("/components/ui")) return 4
if (normalizedTarget.endsWith("/ui")) return 3
return 0
case "lib":
if (aliasBase === "#lib" || aliasBase.endsWith("/lib")) return 5
if (normalizedTarget.endsWith("/lib")) return 4
if (includesPathSegment(normalizedTarget, "lib")) return 3
return 0
case "hooks":
if (aliasBase === "#hooks" || aliasBase.endsWith("/hooks")) return 5
if (normalizedTarget.endsWith("/hooks")) return 4
if (includesPathSegment(normalizedTarget, "hooks")) return 3
return 0
case "utils":
if (aliasBase === "#utils" || aliasBase.endsWith("/utils")) return 5
if (normalizedTarget.endsWith("/lib/utils")) return 4
if (normalizedTarget.endsWith("/utils")) return 3
return 0
}
}
function normalizeTarget(target: string) {
return target
.replace(/\/\*$/, "")
.replace(/\*$/, "")
.replace(/\/index\.[^/]+$/, "")
}
function includesPathSegment(value: string, segment: string) {
return (
value === segment ||
value.includes(`/${segment}`) ||
value.includes(`${segment}/`)
)
}
function getSharedPackageImportPrefix(aliasBases: string[]) {
const sharedSegments = aliasBases
.map((aliasBase) => aliasBase.slice(1).split("/").filter(Boolean))
.reduce<string[]>((shared, segments, index) => {
if (!index) {
return segments
}
return shared.filter((segment, segmentIndex) => {
return segments[segmentIndex] === segment
})
}, [])
return sharedSegments.length ? `#${sharedSegments.join("/")}` : "#"
}

View File

@@ -1,13 +1,139 @@
import { getPatternWildcardValue } from "@/src/utils/import-matcher"
import {
resolvePackageImport,
type ImportEmitMode,
} from "@/src/utils/package-imports"
import { resolveWorkspacePackageExport } from "@/src/utils/workspace"
import { createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths"
export type ResolvedImport = {
path: string
source: "tsconfig_paths" | "package_imports" | "workspace_package_exports"
matchedAlias: string
matchedTarget: string
emitMode: ImportEmitMode
}
type ResolveImportConfig = Pick<
ConfigLoaderSuccessResult,
"absoluteBaseUrl" | "paths"
> & {
cwd?: string
}
export async function resolveImportWithMetadata(
importPath: string,
config: ResolveImportConfig
) {
const cwd = config.cwd ?? config.absoluteBaseUrl
if (importPath.startsWith("#")) {
const resolved = resolvePackageImport(importPath, cwd)
if (resolved) {
return {
path: resolved.path,
source: "package_imports",
matchedAlias: resolved.matchedAlias,
matchedTarget: resolved.matchedTarget,
emitMode: resolved.emitMode,
} satisfies ResolvedImport
}
}
const workspaceResolved = await resolveWorkspacePackageExport(importPath, cwd)
if (workspaceResolved) {
return {
path: workspaceResolved.path,
source: "workspace_package_exports",
matchedAlias: workspaceResolved.matchedAlias,
matchedTarget: workspaceResolved.matchedTarget,
emitMode: workspaceResolved.emitMode,
} satisfies ResolvedImport
}
return resolveFromTsconfigPaths(importPath, config)
}
export async function resolveImport(
importPath: string,
config: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
config: ResolveImportConfig
) {
return createMatchPath(config.absoluteBaseUrl, config.paths)(
return (await resolveImportWithMetadata(importPath, config))?.path ?? null
}
export function isLocalAliasImport(
moduleSpecifier: string,
aliasPrefix: string | null
) {
// Workspace package exports such as `@workspace/ui/...` are already the final
// import specifiers we want to keep, so they are intentionally excluded here.
if (moduleSpecifier.startsWith("#")) {
return true
}
if (!aliasPrefix) {
return false
}
return moduleSpecifier.startsWith(`${aliasPrefix}/`)
}
function isScopedPackageSpecifier(importPath: string) {
return /^@[^/]+\/[^/]+(?:\/.*)?$/.test(importPath)
}
function resolveFromTsconfigPaths(
importPath: string,
config: ResolveImportConfig
) {
const matchedPath = createMatchPath(config.absoluteBaseUrl, config.paths)(
importPath,
undefined,
() => true,
[".ts", ".tsx", ".jsx", ".js", ".css"]
)
if (!matchedPath) {
return null
}
const matchedPattern = findMatchingTsPathPattern(importPath, config.paths)
if (!matchedPattern && isScopedPackageSpecifier(importPath)) {
return null
}
return {
path: matchedPath,
source: "tsconfig_paths",
matchedAlias: matchedPattern?.key ?? importPath,
matchedTarget: matchedPattern?.target ?? matchedPath,
emitMode: "strip_extension",
}
}
function findMatchingTsPathPattern(
importPath: string,
paths: ConfigLoaderSuccessResult["paths"]
) {
for (const [key, targets] of Object.entries(paths)) {
const targetList = Array.isArray(targets) ? targets : [targets]
const wildcardValue = getPatternWildcardValue(importPath, key)
if (wildcardValue === null) {
continue
}
return {
key,
target:
targetList[0]?.includes("*") && wildcardValue !== null
? targetList[0].replace(/\*/g, wildcardValue)
: targetList[0],
}
}
return null
}

View File

@@ -9,10 +9,12 @@ export const transformImport: Transformer = async ({
}) => {
const utilsAlias = config.aliases?.utils
const workspaceAlias =
typeof utilsAlias === "string" && utilsAlias.includes("/")
? utilsAlias.split("/")[0]
typeof utilsAlias === "string"
? getWorkspaceAliasFromUtilsAlias(utilsAlias)
: "@"
const utilsImport = `${workspaceAlias}/lib/utils`
const utilsImport = workspaceAlias
? `${workspaceAlias}/lib/utils`
: "@/lib/utils"
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
return sourceFile
@@ -55,6 +57,8 @@ function updateImportAliases(
config: Config,
isRemote: boolean = false
) {
moduleSpecifier = normalizeImportSpecifier(moduleSpecifier, config)
// Not a local import.
if (!moduleSpecifier.startsWith("@/") && !isRemote) {
return moduleSpecifier
@@ -65,9 +69,41 @@ function updateImportAliases(
moduleSpecifier = moduleSpecifier.replace(/^@\//, `@/registry/new-york/`)
}
if (moduleSpecifier === "@/registry") {
return config.aliases.components
}
// Not a registry import.
if (!moduleSpecifier.startsWith("@/registry/")) {
// We fix the alias and return.
if (moduleSpecifier === "@/lib/utils" && config.aliases.utils) {
return config.aliases.utils
}
if (
config.aliases.ui &&
moduleSpecifier.match(/^@\/components\/ui(?=\/|$)/)
) {
return moduleSpecifier.replace(/^@\/components\/ui/, config.aliases.ui)
}
if (
config.aliases.components &&
moduleSpecifier.match(/^@\/components(?=\/|$)/)
) {
return moduleSpecifier.replace(
/^@\/components/,
config.aliases.components
)
}
if (config.aliases.hooks && moduleSpecifier.match(/^@\/hooks(?=\/|$)/)) {
return moduleSpecifier.replace(/^@\/hooks/, config.aliases.hooks)
}
if (config.aliases.lib && moduleSpecifier.match(/^@\/lib(?=\/|$)/)) {
return moduleSpecifier.replace(/^@\/lib/, config.aliases.lib)
}
const alias = config.aliases.components.split("/")[0]
return moduleSpecifier.replace(/^@\//, `${alias}/`)
}
@@ -79,6 +115,13 @@ function updateImportAliases(
)
}
if (
config.aliases.utils &&
moduleSpecifier.match(/^@\/registry\/(.+)\/lib\/utils$/)
) {
return config.aliases.utils
}
if (
config.aliases.components &&
moduleSpecifier.match(/^@\/registry\/(.+)\/components/)
@@ -111,3 +154,71 @@ function updateImportAliases(
config.aliases.components
)
}
function getWorkspaceAliasFromUtilsAlias(utilsAlias: string) {
// `#...` utils aliases are handled by package-import normalization and should
// not be treated as workspace package roots.
if (utilsAlias.startsWith("#")) {
return ""
}
if (utilsAlias.endsWith("/lib/utils")) {
return utilsAlias.slice(0, -"/lib/utils".length)
}
if (utilsAlias.startsWith("@")) {
const [scope, name] = utilsAlias.split("/")
return scope && name ? `${scope}/${name}` : utilsAlias
}
const slashIndex = utilsAlias.indexOf("/")
return slashIndex === -1 ? utilsAlias : utilsAlias.slice(0, slashIndex)
}
function normalizeImportSpecifier(moduleSpecifier: string, config: Config) {
if (moduleSpecifier === "#registry") {
return "@/registry"
}
if (moduleSpecifier.startsWith("#/")) {
return moduleSpecifier.replace(/^#\//, "@/")
}
if (moduleSpecifier.startsWith("#registry/")) {
return moduleSpecifier.replace(/^#registry\//, "@/registry/")
}
// We only normalize the standard shadcn alias slots here so the rest of the
// transformer can keep operating on the canonical `@/...` forms it already
// understands.
for (const { alias, normalized } of getConfigAliasNormalizations(config)) {
if (moduleSpecifier === alias) {
return normalized
}
if (moduleSpecifier.startsWith(`${alias}/`)) {
return `${normalized}${moduleSpecifier.slice(alias.length)}`
}
}
return moduleSpecifier
}
function getConfigAliasNormalizations(config: Config) {
if (!config.aliases) {
return []
}
return [
{ alias: config.aliases.ui, normalized: "@/components/ui" },
{ alias: config.aliases.components, normalized: "@/components" },
{ alias: config.aliases.hooks, normalized: "@/hooks" },
{ alias: config.aliases.lib, normalized: "@/lib" },
{ alias: config.aliases.utils, normalized: "@/lib/utils" },
]
.filter(
(entry): entry is { alias: string; normalized: string } =>
typeof entry.alias === "string" && entry.alias.startsWith("#")
)
.sort((a, b) => b.alias.length - a.alias.length)
}

View File

@@ -15,7 +15,11 @@ import { Config } from "@/src/utils/get-config"
import { getProjectInfo, ProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { resolveImport } from "@/src/utils/resolve-import"
import { resolvePackageImport } from "@/src/utils/package-imports"
import {
isLocalAliasImport,
resolveImportWithMetadata,
} from "@/src/utils/resolve-import"
import { spinner } from "@/src/utils/spinner"
import { transform } from "@/src/utils/transformers"
import { transformAsChild } from "@/src/utils/transformers/transform-aschild"
@@ -31,9 +35,12 @@ import { transformRtl } from "@/src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import prompts from "prompts"
import { Project, ScriptKind } from "ts-morph"
import { loadConfig } from "tsconfig-paths"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { z } from "zod"
const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]
const NON_ALIAS_RESOLVED_PATH_KEYS = new Set(["tailwindConfig", "tailwindCss"])
export async function updateFiles(
files: RegistryItem["files"],
config: Config,
@@ -45,6 +52,7 @@ export async function updateFiles(
isRemote?: boolean
isWorkspace?: boolean
path?: string
plannedFiles?: RegistryItem["files"]
supportedFontMarkers?: string[]
}
) {
@@ -73,6 +81,19 @@ export async function updateFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
const tsConfig = loadConfig(config.resolvedPaths.cwd)
const plannedFilePaths = getPlannedFilePaths(
options.plannedFiles ?? files,
config,
{
isSrcDir: projectInfo?.isSrcDir,
framework: projectInfo?.framework.name,
path: options.path,
}
)
const importRewriteProject = new Project({
compilerOptions: {},
})
let filesCreated: string[] = []
let filesUpdated: string[] = []
@@ -176,10 +197,19 @@ export async function updateFiles(
// Skip the file if it already exists and the content is the same.
// Exception: Don't skip .env files as we merge content instead of replacing
if (existingFile && !isEnvFile(filePath)) {
const resolvedContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project: importRewriteProject,
projectInfo,
resolvedPath: filePath,
tsConfig,
})
const existingFileContent = await fs.readFile(filePath, "utf-8")
if (
isContentSame(existingFileContent, content, {
isContentSame(existingFileContent, resolvedContent, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
@@ -261,7 +291,7 @@ export async function updateFiles(
}
const allFiles = [...filesCreated, ...filesUpdated, ...filesSkipped]
const updatedFiles = await resolveImports(allFiles, config)
const updatedFiles = await resolveImports(allFiles, config, plannedFilePaths)
// Let's update filesUpdated with the updated files.
filesUpdated.push(...updatedFiles)
@@ -519,7 +549,11 @@ export function resolvePageTarget(
return ""
}
async function resolveImports(filePaths: string[], config: Config) {
async function resolveImports(
filePaths: string[],
config: Config,
plannedFilePaths: string[] = filePaths
) {
const project = new Project({
compilerOptions: {},
})
@@ -554,47 +588,138 @@ async function resolveImports(filePaths: string[], config: Config) {
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
continue
}
const rewrittenContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project,
projectInfo,
resolvedPath,
sourceFile,
tsConfig,
})
const importDeclarations = sourceFile.getImportDeclarations()
for (const importDeclaration of importDeclarations) {
if (rewrittenContent === content) {
continue
}
await fs.writeFile(resolvedPath, rewrittenContent, "utf-8")
updatedFiles.push(filepath)
}
return updatedFiles
}
export function getPlannedFilePaths(
files: RegistryItem["files"],
config: Config,
options: {
isSrcDir?: boolean
framework?: ProjectInfo["framework"]["name"]
path?: string
}
) {
return (files ?? [])
?.filter((file): file is NonNullable<typeof file> => !!file?.content)
.map((file, index) => {
let filePath = resolveFilePath(file, config, {
isSrcDir: options.isSrcDir,
framework: options.framework,
commonRoot: findCommonRoot(
(files ?? []).map((entry) => entry.path),
file.path
),
path: options.path,
fileIndex: index,
})
if (!filePath) {
return null
}
if (!config.tsx) {
filePath = filePath.replace(/\.tsx?$/, (match) =>
match === ".tsx" ? ".jsx" : ".js"
)
}
return path.relative(config.resolvedPaths.cwd, filePath)
})
.filter((filePath): filePath is string => !!filePath)
}
export async function rewriteResolvedImportsInContent({
content,
resolvedPath,
filePaths,
config,
projectInfo,
tsConfig,
project,
sourceFile,
}: {
content: string
resolvedPath: string
filePaths: string[]
config: Config
projectInfo: ProjectInfo | null
tsConfig: ReturnType<typeof loadConfig>
project: Project
sourceFile?: ReturnType<Project["createSourceFile"]>
}) {
if (!projectInfo || tsConfig.resultType === "failed") {
return content
}
const ext = path.extname(resolvedPath)
if (![".tsx", ".ts", ".jsx", ".js"].includes(ext)) {
return content
}
const createdSourceFile =
sourceFile === undefined
? project.createSourceFile(
path.join(
tmpdir(),
`shadcn-${Math.random().toString(36).slice(2)}${ext || ".tsx"}`
),
content,
{
scriptKind: ScriptKind.TSX,
overwrite: true,
}
)
: null
const workingSourceFile = sourceFile ?? createdSourceFile!
try {
let hasChanges = false
for (const importDeclaration of workingSourceFile.getImportDeclarations()) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
// Filter out non-local imports.
if (
projectInfo?.aliasPrefix &&
!moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`)
!isLocalAliasImport(moduleSpecifier, projectInfo.aliasPrefix ?? null)
) {
continue
}
// Find the probable import file path.
// This is where we expect to find the file on disk.
const probableImportFilePath = await resolveImport(
const resolvedImportFilePath = await resolveImportFilePathForRewrite(
moduleSpecifier,
tsConfig
)
if (!probableImportFilePath) {
continue
}
// Find the actual import file path.
// This is the path where the file has been installed.
const resolvedImportFilePath = resolveModuleByProbablePath(
probableImportFilePath,
filePaths,
config
config,
tsConfig
)
if (!resolvedImportFilePath) {
continue
}
// Convert the resolved import file path to an aliased import.
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
projectInfo
projectInfo,
resolvedPath
)
if (!newImport || newImport === moduleSpecifier) {
@@ -602,16 +727,44 @@ async function resolveImports(filePaths: string[], config: Config) {
}
importDeclaration.setModuleSpecifier(newImport)
hasChanges = true
}
// Write the updated content to the file.
await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8")
// Track the updated file.
updatedFiles.push(filepath)
return hasChanges ? workingSourceFile.getFullText() : content
} finally {
if (createdSourceFile) {
project.removeSourceFile(createdSourceFile)
}
}
}
return updatedFiles
async function resolveImportFilePathForRewrite(
moduleSpecifier: string,
filePaths: string[],
config: Config,
tsConfig: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
const probableImportFilePath = (
await resolveImportWithMetadata(moduleSpecifier, {
...tsConfig,
cwd: config.resolvedPaths.cwd,
})
)?.path
const fallbackImportFilePath =
!probableImportFilePath && !moduleSpecifier.startsWith(".")
? resolveImportFromConfiguredAliases(moduleSpecifier, config)
: null
if (!probableImportFilePath && !fallbackImportFilePath) {
return null
}
return resolveModuleByProbablePath(
probableImportFilePath ?? fallbackImportFilePath!,
filePaths,
config
)
}
/**
@@ -694,16 +847,40 @@ export function resolveModuleByProbablePath(
export function toAliasedImport(
filePath: string,
config: Config,
projectInfo: ProjectInfo
projectInfo: ProjectInfo,
importerPath?: string
): string | null {
const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath))
// 1⃣ Find the longest matching alias root in resolvedPaths
// e.g. key="ui", root="/…/components/ui" beats key="components"
const matches = Object.entries(config.resolvedPaths)
.filter(
([, root]) => root && abs.startsWith(path.normalize(root + path.sep))
)
.filter(([key, root]) => {
if (!root || NON_ALIAS_RESOLVED_PATH_KEYS.has(key)) {
return false
}
const normalizedRoot = path.normalize(root)
if (abs === normalizedRoot) {
// Only allow exact-equality match for true exact-key package imports
// (e.g. `#utils` → `./src/lib/utils.ts`). Path-style aliases that
// resolve through a wildcard (e.g. `#lib/utils` via `#lib/*`) must
// fall back to the directory alias so the wildcard's emit-mode
// (preserve/strip extension) is honored.
const aliasValue = config.aliases[key as keyof typeof config.aliases]
if (typeof aliasValue !== "string" || !aliasValue.startsWith("#")) {
return true
}
const resolved = resolvePackageImport(
aliasValue,
config.resolvedPaths.cwd
)
return resolved !== null && !resolved.matchedAlias.includes("*")
}
return abs.startsWith(path.normalize(root + path.sep))
})
.sort((a, b) => b[1].length - a[1].length)
if (matches.length === 0) {
@@ -716,10 +893,31 @@ export function toAliasedImport(
// force POSIX-style separators
rel = rel.split(path.sep).join("/") // e.g. "button/index.tsx"
const aliasBase =
aliasKey === "cwd"
? projectInfo.aliasPrefix
: config.aliases[aliasKey as keyof typeof config.aliases]
if (!aliasBase) {
return null
}
if (aliasBase.startsWith("#")) {
const packageImport = resolvePackageImport(
aliasBase,
config.resolvedPaths.cwd
)
if (packageImport) {
return (
toPackageImport(aliasBase, rel, packageImport) ??
(importerPath ? toRelativeImport(importerPath, abs) : null)
)
}
}
// 3⃣ Strip code-file extensions, keep others (css, json, etc.)
const ext = path.posix.extname(rel)
const codeExts = [".ts", ".tsx", ".js", ".jsx"]
const keepExt = codeExts.includes(ext) ? "" : ext
const keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
// 4⃣ Collapse "/index" to its directory
@@ -728,26 +926,138 @@ export function toAliasedImport(
}
// 5⃣ Build the aliased path
// config.aliases[aliasKey] is e.g. "@/components/ui"
const aliasBase =
aliasKey === "cwd"
? projectInfo.aliasPrefix
: config.aliases[aliasKey as keyof typeof config.aliases]
if (!aliasBase) {
return null
}
// if noExt is empty (i.e. file was exactly at the root), we import the root
let suffix = noExt === "" ? "" : `/${noExt}`
// Remove /src from suffix.
// Alias will handle this.
suffix = suffix.replace("/src", "")
// 6⃣ Prepend the prefix from projectInfo (e.g. "@") if needed
// but usually config.aliases already include it.
return `${aliasBase}${suffix}${keepExt}`
}
function toPackageImport(
aliasBase: string,
relativePath: string,
packageImport: ReturnType<typeof resolvePackageImport> extends infer T
? Exclude<T, null>
: never
) {
const ext = path.posix.extname(relativePath)
const keepExt =
CODE_EXTENSIONS.includes(ext) &&
packageImport.emitMode === "strip_extension"
? ""
: ext
const normalizedRelativePath = relativePath
? relativePath.slice(0, relativePath.length - ext.length) + keepExt
: ""
if (!packageImport.matchedAlias.includes("*")) {
return normalizedRelativePath === "" || normalizedRelativePath === "index"
? aliasBase
: null
}
return normalizedRelativePath
? `${aliasBase}/${normalizedRelativePath}`
: aliasBase
}
function resolveImportFromConfiguredAliases(
moduleSpecifier: string,
config: Config
) {
const aliasEntries = getConfiguredAliasEntries(config)
for (const entry of aliasEntries) {
if (
moduleSpecifier === entry.alias ||
moduleSpecifier === entry.canonical
) {
return entry.rootPath
}
if (moduleSpecifier.startsWith(`${entry.alias}/`)) {
return path.join(
entry.rootPath,
moduleSpecifier.slice(entry.alias.length + 1)
)
}
if (moduleSpecifier.startsWith(`${entry.canonical}/`)) {
return path.join(
entry.rootPath,
moduleSpecifier.slice(entry.canonical.length + 1)
)
}
}
return null
}
function getConfiguredAliasEntries(config: Config) {
return [
{
alias: config.aliases.ui,
canonical: "@/components/ui",
rootPath: config.resolvedPaths.ui,
},
{
alias: config.aliases.components,
canonical: "@/components",
rootPath: config.resolvedPaths.components,
},
{
alias: config.aliases.hooks,
canonical: "@/hooks",
rootPath: config.resolvedPaths.hooks,
},
{
alias: config.aliases.lib,
canonical: "@/lib",
rootPath: config.resolvedPaths.lib,
},
{
alias: config.aliases.utils,
canonical: "@/lib/utils",
rootPath: config.resolvedPaths.utils,
},
]
.filter(
(
entry
): entry is {
alias: string
canonical: string
rootPath: string
} => typeof entry.alias === "string" && typeof entry.rootPath === "string"
)
.sort(
(a, b) =>
b.alias.length - a.alias.length ||
b.canonical.length - a.canonical.length
)
}
function toRelativeImport(fromFilePath: string, targetFilePath: string) {
let rel = path.relative(path.dirname(fromFilePath), targetFilePath)
rel = rel.split(path.sep).join("/")
const ext = path.posix.extname(rel)
const keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
if (noExt.endsWith("/index")) {
noExt = noExt.slice(0, -"/index".length)
}
if (!noExt.startsWith(".")) {
noExt = `./${noExt}`
}
return `${noExt}${keepExt}`
}
function _isNext16Middleware(
filePath: string,
projectInfo: ProjectInfo | null,

View File

@@ -0,0 +1,251 @@
import path from "path"
import { getWorkspacePatterns } from "@/src/utils/get-monorepo-info"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
getImportTargetEmitMode,
resolveImportEntryMatch,
resolveLocalPathTarget,
type ImportEmitMode,
type ImportResolutionEntry,
type ImportResolutionMatch,
} from "@/src/utils/import-matcher"
import fg from "fast-glob"
import fs from "fs-extra"
type WorkspacePackageInfo = {
packageName: string
packageRoot: string
}
type WorkspacePackageExportEntry = ImportResolutionEntry
export type WorkspacePackageExportMatch = ImportResolutionMatch
const workspacePackageCache = new Map<
string,
Map<string, WorkspacePackageInfo>
>()
const workspaceExportEntriesCache = new Map<
string,
WorkspacePackageExportEntry[]
>()
const workspaceRootCache = new Map<string, string | null>()
export async function resolveWorkspacePackageExport(
importPath: string,
cwd: string
) {
const specifier = parsePackageSpecifier(importPath)
if (!specifier) {
return null
}
const workspacePackage = await findWorkspacePackage(
cwd,
specifier.packageName
)
if (!workspacePackage) {
return null
}
return resolveImportEntryMatch(
importPath,
getWorkspacePackageExportEntries(workspacePackage)
)
}
function getWorkspacePackageExportEntries(
workspacePackage: WorkspacePackageInfo
) {
const cacheKey = `${workspacePackage.packageRoot}:${workspacePackage.packageName}`
const cachedEntries = workspaceExportEntriesCache.get(cacheKey)
if (cachedEntries) {
return cachedEntries
}
const packageInfo = getPackageInfo(workspacePackage.packageRoot, false)
const exportsField = packageInfo?.exports
if (
!exportsField ||
typeof exportsField !== "object" ||
Array.isArray(exportsField)
) {
workspaceExportEntriesCache.set(cacheKey, [])
return []
}
const entries: WorkspacePackageExportEntry[] = []
for (const [key, value] of Object.entries(exportsField)) {
if (key !== "." && !key.startsWith("./")) {
continue
}
const target = resolveLocalPathTarget(value)
if (!target) {
continue
}
const aliasBase = getAliasBase(workspacePackage.packageName, key)
entries.push({
key: key.includes("*") ? `${aliasBase}/*` : aliasBase,
aliasBase,
target,
emitMode: getImportTargetEmitMode(target),
hasWildcard: key.includes("*"),
rootDir: workspacePackage.packageRoot,
})
}
workspaceExportEntriesCache.set(cacheKey, entries)
return entries
}
async function findWorkspacePackage(cwd: string, packageName: string) {
const workspaceRoot = await findWorkspaceRoot(cwd)
if (!workspaceRoot) {
return null
}
const cachedPackages = workspacePackageCache.get(workspaceRoot)
if (cachedPackages?.has(packageName)) {
return cachedPackages.get(packageName) ?? null
}
const workspacePackages = await loadWorkspacePackages(workspaceRoot)
workspacePackageCache.set(workspaceRoot, workspacePackages)
return workspacePackages.get(packageName) ?? null
}
async function loadWorkspacePackages(root: string) {
const patterns = await getWorkspacePatterns(root)
const packageMap = new Map<string, WorkspacePackageInfo>()
if (!patterns.length) {
return packageMap
}
const packageJsonPaths = await fg(
patterns.map((pattern) =>
path.posix.join(pattern.split(path.sep).join("/"), "package.json")
),
{
cwd: root,
ignore: ["**/node_modules/**"],
}
)
for (const packageJsonPath of packageJsonPaths) {
const packageRoot = path.resolve(root, path.dirname(packageJsonPath))
const packageInfo = getPackageInfo(packageRoot, false)
const name = packageInfo?.name
if (!name) {
continue
}
packageMap.set(name, {
packageName: name,
packageRoot,
})
}
return packageMap
}
async function findWorkspaceRoot(cwd: string) {
const start = path.resolve(cwd)
const cachedRoot = workspaceRootCache.get(start)
if (cachedRoot !== undefined) {
return cachedRoot
}
let current = start
const gitRoot = await findGitRoot(start)
while (true) {
const patterns = await getWorkspacePatterns(current)
if (patterns.length) {
workspaceRootCache.set(start, current)
return current
}
if (gitRoot && current === gitRoot) {
workspaceRootCache.set(start, null)
return null
}
const parent = path.dirname(current)
if (parent === current) {
workspaceRootCache.set(start, null)
return null
}
current = parent
}
}
async function findGitRoot(cwd: string) {
let current = path.resolve(cwd)
while (true) {
if (fs.existsSync(path.resolve(current, ".git"))) {
return current
}
const parent = path.dirname(current)
if (parent === current) {
return null
}
current = parent
}
}
function parsePackageSpecifier(importPath: string) {
if (
importPath.startsWith("#") ||
importPath.startsWith(".") ||
path.isAbsolute(importPath)
) {
return null
}
const segments = importPath.split("/")
if (importPath.startsWith("@")) {
if (segments.length < 2) {
return null
}
return {
packageName: `${segments[0]}/${segments[1]}`,
}
}
return {
packageName: segments[0],
}
}
function getAliasBase(packageName: string, exportKey: string) {
if (exportKey === ".") {
return packageName
}
const normalizedKey = exportKey.slice(2).replace(/\/\*$/, "")
return normalizedKey ? `${packageName}/${normalizedKey}` : packageName
}

View File

@@ -0,0 +1,16 @@
{
"style": "new-york",
"tailwind": {
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"utils": "#lib/utils"
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "config-imports-extensions",
"type": "module",
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": true,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#utils"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "config-imports",
"type": "module",
"imports": {
"#components/*": "./src/components/*",
"#components/ui/*": "./src/components/ui/*",
"#lib/*": "./src/lib/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts"
}
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View File

@@ -0,0 +1,21 @@
{
"name": "next-app-imports",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"tailwindcss": "^3.0.0"
},
"imports": {
"#components/*": "./src/components/*",
"#components/ui/*": "./src/components/ui/*",
"#lib/*": "./src/lib/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts"
}
}

View File

@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello</div>
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
export default {}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"resolvePackageJsonImports": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "vite-app-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"tailwindcss": "^4.1.11"
},
"imports": {
"#custom/*": "./src/*"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "vite-monorepo-imports",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@workspace/ui",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}
}

View File

@@ -0,0 +1,3 @@
export function cn(...inputs: Array<string | undefined | false | null>) {
return inputs.filter(Boolean).join(" ")
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "vite-partial-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"imports": {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*"
},
"dependencies": {
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,12 @@
{
"name": "vite-root-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"tailwindcss": "^4.1.11"
},
"imports": {
"#*": "./src/*"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "with-package-imports",
"type": "module",
"imports": {
"#components/*": "./src/components/*",
"#components-ext/*": "./src/components/*.tsx",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts",
"#outside/*": "../outside/*",
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
}
}

View File

@@ -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
}
"

View File

@@ -1,6 +1,14 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { afterEach, describe, expect, test, vi } from "vitest"
import { resolveRegistryTree } from "../../src/registry/resolver"
import { addComponents } from "../../src/utils/add-components"
import type { Config } from "../../src/utils/get-config"
import { findPackageRoot, getWorkspaceConfig } from "../../src/utils/get-config"
import { updateFiles } from "../../src/utils/updaters/update-files"
import { updateFonts } from "../../src/utils/updaters/update-fonts"
// Mock all external dependencies.
vi.mock("../../src/registry/resolver", () => ({
@@ -18,13 +26,20 @@ vi.mock("../../src/utils/get-config", async () => {
}
})
vi.mock("../../src/utils/updaters/update-files", () => ({
updateFiles: vi.fn().mockResolvedValue({
filesCreated: [],
filesUpdated: [],
filesSkipped: [],
}),
}))
vi.mock("../../src/utils/updaters/update-files", async () => {
const actual = (await vi.importActual(
"../../src/utils/updaters/update-files"
)) as typeof import("../../src/utils/updaters/update-files")
return {
...actual,
updateFiles: vi.fn().mockResolvedValue({
filesCreated: [],
filesUpdated: [],
filesSkipped: [],
}),
}
})
vi.mock("../../src/utils/updaters/update-dependencies", () => ({
updateDependencies: vi.fn().mockResolvedValue(undefined),
@@ -47,9 +62,16 @@ vi.mock("../../src/utils/updaters/update-css", () => ({
updateCss: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("../../src/utils/get-project-info", () => ({
getProjectTailwindVersionFromConfig: vi.fn().mockResolvedValue("4"),
}))
vi.mock("../../src/utils/get-project-info", async () => {
const actual = (await vi.importActual(
"../../src/utils/get-project-info"
)) as typeof import("../../src/utils/get-project-info")
return {
...actual,
getProjectTailwindVersionFromConfig: vi.fn().mockResolvedValue("4"),
}
})
vi.mock("../../src/utils/spinner", () => ({
spinner: vi.fn().mockReturnValue({
@@ -69,17 +91,13 @@ vi.mock("../../src/utils/logger", () => ({
},
}))
import { addComponents } from "../../src/utils/add-components"
import { resolveRegistryTree } from "../../src/registry/resolver"
import {
findPackageRoot,
getWorkspaceConfig,
} from "../../src/utils/get-config"
import { updateFiles } from "../../src/utils/updaters/update-files"
import { updateFonts } from "../../src/utils/updaters/update-fonts"
afterEach(() => {
vi.clearAllMocks()
vi.mocked(updateFiles).mockResolvedValue({
filesCreated: [],
filesUpdated: [],
filesSkipped: [],
})
})
function createMockConfig(overrides: Partial<Config> = {}): Config {
@@ -114,6 +132,79 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
} as Config
}
function createPackageImportConfig(
cwd: string,
overrides: Partial<Config> = {}
): Config {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
},
registries: {},
...overrides,
} as Config
}
async function writePackageImportProject(cwd: string) {
await fs.ensureDir(path.resolve(cwd, "src"))
await fs.writeJson(
path.resolve(cwd, "package.json"),
{
name: path.basename(cwd),
type: "module",
dependencies: {
tailwindcss: "^4.0.0",
},
imports: {
"#components/*": "./src/components/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts",
},
},
{ spaces: 2 }
)
await fs.writeJson(
path.resolve(cwd, "tsconfig.json"),
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
resolvePackageJsonImports: true,
},
},
{ spaces: 2 }
)
await fs.writeFile(
path.resolve(cwd, "src/index.css"),
'@import "tailwindcss";\n'
)
await fs.writeFile(path.resolve(cwd, "vite.config.ts"), "export default {}\n")
}
describe("addComponents workspace routing", () => {
test("should route registry:hook files to workspaceConfig.hooks", async () => {
const appConfig = createMockConfig()
@@ -712,6 +803,74 @@ describe("addComponents workspace routing", () => {
)
})
test("should rewrite cross-type imports when files target the same workspace package", async () => {
const actualUpdateFiles = (await vi.importActual(
"../../src/utils/updaters/update-files"
)) as typeof import("../../src/utils/updaters/update-files")
vi.mocked(updateFiles).mockImplementation(actualUpdateFiles.updateFiles)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-cross-type-"))
const appCwd = path.resolve(root, "apps/web")
const uiCwd = path.resolve(root, "packages/ui")
try {
await writePackageImportProject(appCwd)
await writePackageImportProject(uiCwd)
const appConfig = createPackageImportConfig(appCwd)
const uiConfig = createPackageImportConfig(uiCwd)
vi.mocked(getWorkspaceConfig).mockResolvedValue({
components: appConfig,
ui: uiConfig,
lib: appConfig,
hooks: appConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "example-card",
files: [
{
path: "registry/components/example-card.tsx",
type: "registry:component",
content: `import { useThing } from "@/hooks/use-thing"
export function ExampleCard() {
useThing()
return null
}
`,
},
{
path: "registry/hooks/use-thing.ts",
type: "registry:hook",
content: `export function useThing() {
return true
}
`,
},
],
dependencies: [],
devDependencies: [],
})
await addComponents(["example-card"], appConfig, {
overwrite: true,
silent: true,
})
const componentContent = await fs.readFile(
path.resolve(appCwd, "src/components/example-card.tsx"),
"utf-8"
)
expect(componentContent).toContain(`from "../hooks/use-thing"`)
expect(componentContent).not.toContain(`from "#hooks/use-thing"`)
} finally {
await fs.remove(root)
}
})
test("should call updateFonts with app config, not workspace config", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({

View File

@@ -1,6 +1,9 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterEach, describe, expect, test, vi } from "vitest"
import type { Config } from "../../src/utils/get-config"
import { getConfig } from "../../src/utils/get-config"
// Mock external dependencies.
vi.mock("../../src/registry/resolver", () => ({
@@ -94,9 +97,22 @@ import {
} from "../../src/utils/dry-run-formatter"
import type { DryRunResult } from "../../src/utils/dry-run"
import { resolveRegistryTree } from "../../src/registry/resolver"
import { getProjectInfo } from "../../src/utils/get-project-info"
import { transform } from "../../src/utils/transformers"
import { transformAsChild } from "../../src/utils/transformers/transform-aschild"
import { transformCleanup } from "../../src/utils/transformers/transform-cleanup"
import { transformCssVars as transformCssVarsTransformer } from "../../src/utils/transformers/transform-css-vars"
import { transformIcons } from "../../src/utils/transformers/transform-icons"
import { transformImport } from "../../src/utils/transformers/transform-import"
import { transformMenu } from "../../src/utils/transformers/transform-menu"
import { transformRsc } from "../../src/utils/transformers/transform-rsc"
import { transformRtl } from "../../src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "../../src/utils/transformers/transform-tw-prefix"
afterEach(() => {
vi.clearAllMocks()
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(fs.readFile).mockResolvedValue("" as never)
})
function createMockConfig(overrides: Partial<Config> = {}): Config {
@@ -408,6 +424,244 @@ describe("dryRunComponents", () => {
dryRunComponents(["nonexistent"], config)
).rejects.toThrow("Failed to fetch components from registry.")
})
test("should skip package-import files when final rewritten content matches", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../fixtures"),
"temp-dry-run-package-import-same"
)
const actualFs = (await vi.importActual("fs")) as typeof import("fs")
try {
vi.mocked(existsSync).mockImplementation(actualFs.existsSync)
vi.mocked(fs.readFile).mockImplementation(actualFs.promises.readFile as never)
vi.mocked(getProjectInfo).mockResolvedValue({
framework: { name: "vite" } as any,
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
})
await actualFs.promises.rm(tempDir, { recursive: true, force: true })
await actualFs.promises.mkdir(path.join(tempDir, "src", "components", "ui"), {
recursive: true,
})
await actualFs.promises.mkdir(path.join(tempDir, "src", "lib"), {
recursive: true,
})
await actualFs.promises.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(
{
name: "temp-dry-run-package-import-same",
type: "module",
imports: {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*",
},
},
null,
2
),
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(
{
files: [],
references: [{ path: "./tsconfig.app.json" }],
},
null,
2
),
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "tsconfig.app.json"),
JSON.stringify(
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
baseUrl: ".",
paths: {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"],
},
},
},
null,
2
),
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "src", "index.css"),
'@import "tailwindcss";\n',
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "src", "lib", "utils.ts"),
"export function cn(...inputs: unknown[]) {\n return inputs\n}\n",
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "src", "components", "ui", "button.tsx"),
`import { cn } from "#lib/utils.ts"
export function Button() {
return <button>{cn("button")}</button>
}
`,
"utf-8"
)
const config = createMockConfig({
rsc: false,
aliases: {
components: "#components",
utils: "#lib/utils",
ui: "#components/ui",
lib: "#lib",
hooks: undefined,
},
resolvedPaths: {
cwd: tempDir,
tailwindConfig: "",
tailwindCss: path.join(tempDir, "src", "index.css"),
utils: path.join(tempDir, "src", "lib", "utils.ts"),
components: path.join(tempDir, "src", "components"),
lib: path.join(tempDir, "src", "lib"),
hooks: path.join(tempDir, "src", "hooks"),
ui: path.join(tempDir, "src", "components", "ui"),
},
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "button",
files: [
{
path: "registry/default/ui/button.tsx",
type: "registry:ui",
content: `import { cn } from "#lib/utils"
export function Button() {
return <button>{cn("button")}</button>
}
`,
},
],
dependencies: [],
devDependencies: [],
})
const result = await dryRunComponents(["button"], config)
expect(result.files).toHaveLength(1)
expect(result.files[0]).toMatchObject({
path: "src/components/ui/button.tsx",
action: "skip",
})
expect(result.files[0].content).toContain(`from "#lib/utils.ts"`)
} finally {
await actualFs.promises.rm(tempDir, { recursive: true, force: true })
}
})
test("should rewrite app-local files to workspace utils aliases in monorepo dry-runs", async () => {
const actualFs = (await vi.importActual("fs")) as typeof import("fs")
const actualTransformModule = (await vi.importActual(
"../../src/utils/transformers"
)) as typeof import("../../src/utils/transformers")
const actualTransformImportModule = (await vi.importActual(
"../../src/utils/transformers/transform-import"
)) as typeof import("../../src/utils/transformers/transform-import")
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
vi.mocked(existsSync).mockImplementation(actualFs.existsSync)
vi.mocked(fs.readFile).mockImplementation(actualFs.promises.readFile as never)
vi.mocked(getProjectInfo).mockResolvedValue({
framework: { name: "vite" } as any,
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "../../packages/ui/src/styles/globals.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
})
vi.mocked(transform).mockImplementationOnce(actualTransformModule.transform)
vi.mocked(transformImport).mockImplementationOnce(
actualTransformImportModule.transformImport
)
for (const transformer of [
transformRsc,
transformCssVarsTransformer,
transformTwPrefixes,
transformIcons,
transformMenu,
transformAsChild,
transformRtl,
transformCleanup,
]) {
vi.mocked(transformer).mockImplementationOnce(async ({ sourceFile }) => {
return sourceFile
})
}
const config = await getConfig(cwd)
if (!config) {
throw new Error("Failed to get monorepo app config")
}
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "login-03",
files: [
{
path: "registry/components/login-form.tsx",
type: "registry:component",
content: `import { cn } from "@/lib/utils"
export function LoginForm() {
return <div>{cn("login")}</div>
}
`,
},
],
dependencies: [],
devDependencies: [],
})
const result = await dryRunComponents(["login-03"], config)
expect(result.files).toHaveLength(1)
expect(result.files[0]).toMatchObject({
path: "src/components/login-form.tsx",
action: "create",
type: "registry:component",
})
expect(result.files[0].content).toContain(
`from "@workspace/ui/lib/utils"`
)
expect(result.files[0].content).not.toContain(`from "#lib/utils"`)
})
})
describe("formatDryRunResult", () => {

View File

@@ -1,4 +1,6 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import {
@@ -6,7 +8,9 @@ import {
getBase,
getConfig,
getRawConfig,
getWorkspaceConfig,
} from "../../src/utils/get-config"
import { getProjectConfig } from "../../src/utils/get-project-info"
test("get raw config", async () => {
expect(
@@ -36,6 +40,164 @@ test("get raw config", async () => {
).rejects.toThrowError()
})
test("get project config from package imports", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/next-app-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/styles.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.ts"),
tailwindCss: path.resolve(cwd, "src/app/styles.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from generic package import prefix", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-app-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#custom/components",
ui: "#custom/components/ui",
lib: "#custom/lib",
hooks: "#custom/hooks",
utils: "#custom/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from root package imports", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-root-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from partial package imports", async () => {
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-partial-imports"
)
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get config", async () => {
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-none"))
@@ -196,6 +358,282 @@ test("get config", async () => {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-imports"))
).toEqual({
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/globals.css",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-imports"),
tailwindConfig: path.resolve(
__dirname,
"../fixtures/config-imports",
"tailwind.config.ts"
),
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/app/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/components/ui"
),
lib: path.resolve(__dirname, "../fixtures/config-imports", "src/lib"),
hooks: path.resolve(__dirname, "../fixtures/config-imports", "src/hooks"),
utils: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(
path.resolve(__dirname, "../fixtures/config-imports-extensions")
)
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-imports-extensions"),
tailwindConfig: "",
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/index.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/components/ui"
),
lib: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/lib"
),
hooks: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/hooks"
),
utils: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(
path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
)
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "../../packages/ui/src/styles/globals.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "@workspace/ui/components",
lib: "#lib",
hooks: "#hooks",
utils: "@workspace/ui/lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
),
tailwindConfig: "",
tailwindCss: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/components"
),
lib: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/lib"
),
hooks: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/hooks"
),
utils: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get workspace config resolves cross-package aliases without tsconfig paths", async () => {
const appCwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
const uiCwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui"
)
const config = await getConfig(appCwd)
if (!config) {
throw new Error("Failed to load monorepo app config")
}
expect(await getWorkspaceConfig(config)).toMatchObject({
components: {
resolvedPaths: {
cwd: appCwd,
},
},
ui: {
resolvedPaths: {
cwd: uiCwd,
},
},
lib: {
resolvedPaths: {
cwd: appCwd,
},
},
hooks: {
resolvedPaths: {
cwd: appCwd,
},
},
})
})
test("get workspace config shows an actionable error when a workspace package is missing imports", async () => {
const fixtureRoot = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "shadcn-workspace-config-")
)
try {
await fs.copy(fixtureRoot, tempDir)
const uiPackageJsonPath = path.resolve(tempDir, "packages/ui/package.json")
const uiPackageJson = await fs.readJson(uiPackageJsonPath)
delete uiPackageJson.imports
await fs.writeJson(uiPackageJsonPath, uiPackageJson, { spaces: 2 })
const config = await getConfig(path.resolve(tempDir, "apps/web"))
if (!config) {
throw new Error("Failed to load broken monorepo app config")
}
await expect(getWorkspaceConfig(config)).rejects.toThrowError(
new RegExp(
"Could not resolve the following aliases.*packages/ui.*components, ui, lib, hooks, utils",
"s"
)
)
} finally {
await fs.remove(tempDir)
}
})
test("get workspace config shows an actionable error when a workspace package is missing components.json", async () => {
const fixtureRoot = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "shadcn-workspace-config-")
)
try {
await fs.copy(fixtureRoot, tempDir)
await fs.remove(path.resolve(tempDir, "packages/ui/components.json"))
const config = await getConfig(path.resolve(tempDir, "apps/web"))
if (!config) {
throw new Error("Failed to load broken monorepo app config")
}
await expect(getWorkspaceConfig(config)).rejects.toThrowError(
new RegExp(
"Could not load the workspace config.*packages/ui.*components.json.*path aliases or package imports",
"s"
)
)
} finally {
await fs.remove(tempDir)
}
})
describe("getBase", () => {

View File

@@ -48,6 +48,62 @@ describe("get project info", async () => {
aliasPrefix: "#",
},
},
{
name: "next-app-imports",
type: {
framework: FRAMEWORKS["next-app"],
isSrcDir: true,
isRSC: true,
isTsx: true,
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "src/app/styles.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: "#",
},
},
{
name: "vite-app-imports",
type: {
framework: FRAMEWORKS["vite"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#custom",
},
},
{
name: "vite-root-imports",
type: {
framework: FRAMEWORKS["vite"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
},
},
{
name: "vite-partial-imports",
type: {
framework: FRAMEWORKS["vite"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
},
},
{
name: "next-pages",
type: {

View File

@@ -1,4 +1,6 @@
import { tmpdir } from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import { getTsConfigAliasPrefix } from "../../src/utils/get-project-info"
@@ -29,6 +31,14 @@ describe("get ts config alias prefix", async () => {
name: "next-app-custom-alias",
prefix: "@custom-alias",
},
{
name: "vite-partial-imports",
prefix: "#components",
},
{
name: "vite-root-paths",
prefix: "@",
},
])(`getTsConfigAliasPrefix($name) -> $prefix`, async ({ name, prefix }) => {
expect(
await getTsConfigAliasPrefix(
@@ -36,4 +46,29 @@ describe("get ts config alias prefix", async () => {
)
).toBe(prefix)
})
test("parses JSONC tsconfig files with trailing commas", async () => {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-jsonc-tsconfig-"))
try {
await fs.writeFile(
path.join(cwd, "tsconfig.json"),
`{
// This mirrors the JSONC shape emitted by common TS templates.
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"], // trailing comments are valid JSONC.
},
},
}
`,
"utf8"
)
expect(await getTsConfigAliasPrefix(cwd)).toBe("@")
} finally {
await fs.remove(cwd)
}
})
})

View File

@@ -1,8 +1,13 @@
import path from "path"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { expect, test } from "vitest"
import { describe, expect, test } from "vitest"
import { resolveImport } from "../../src/utils/resolve-import"
import { resolvePackageImport } from "../../src/utils/package-imports"
import {
isLocalAliasImport,
resolveImport,
resolveImportWithMetadata,
} from "../../src/utils/resolve-import"
test("resolve import", async () => {
expect(
@@ -79,3 +84,154 @@ test("resolve import without base url", async () => {
path.resolve(cwd, "foo/bar")
)
})
describe("resolve package imports", () => {
const cwd = path.resolve(__dirname, "../fixtures/with-package-imports")
const config = {
absoluteBaseUrl: cwd,
paths: {},
cwd,
}
test("resolves wildcard imports that preserve extensions", async () => {
const result = await resolveImportWithMetadata(
"#components/button.tsx",
config
)
expect(result).toEqual({
path: path.resolve(cwd, "src/components/button.tsx"),
source: "package_imports",
matchedAlias: "#components/*",
matchedTarget: "./src/components/*",
emitMode: "preserve_extension",
})
})
test("resolves wildcard imports that strip extensions", async () => {
const result = await resolveImportWithMetadata(
"#components-ext/button",
config
)
expect(result).toEqual({
path: path.resolve(cwd, "src/components/button.tsx"),
source: "package_imports",
matchedAlias: "#components-ext/*",
matchedTarget: "./src/components/*.tsx",
emitMode: "strip_extension",
})
})
test("resolves the root alias for wildcard package imports", async () => {
expect(await resolveImport("#components", config)).toEqual(
path.resolve(cwd, "src/components")
)
})
test("resolves exact imports and prefers local conditional targets", async () => {
expect(await resolveImport("#hooks", config)).toEqual(
path.resolve(cwd, "src/hooks/index.ts")
)
expect(await resolveImport("#dep", config)).toEqual(
path.resolve(cwd, "dep-polyfill.js")
)
})
test("ignores package import targets outside the package", async () => {
expect(resolvePackageImport("#outside/file", cwd)).toBeNull()
})
test("falls back to tsconfig paths when package imports do not match", async () => {
expect(
await resolveImportWithMetadata("#/components/ui", {
absoluteBaseUrl: "/Users/shadcn/Projects/foobar",
cwd,
paths: {
"#/*": ["./src/*"],
},
})
).toEqual({
path: "/Users/shadcn/Projects/foobar/src/components/ui",
source: "tsconfig_paths",
matchedAlias: "#/*",
matchedTarget: "./src/components/ui",
emitMode: "strip_extension",
})
})
test("resolves @/ via tsconfig paths and #/ via package imports in a mixed project", async () => {
const tsconfigPath = await resolveImportWithMetadata(
"@/components/button",
{
absoluteBaseUrl: cwd,
cwd,
paths: {
"@/*": ["./src/*"],
},
}
)
expect(tsconfigPath?.source).toBe("tsconfig_paths")
expect(tsconfigPath?.path).toBe(path.resolve(cwd, "src/components/button"))
const packageImportPath = await resolveImportWithMetadata(
"#components/button.tsx",
{
absoluteBaseUrl: cwd,
cwd,
paths: {
"@/*": ["./src/*"],
},
}
)
expect(packageImportPath?.source).toBe("package_imports")
expect(packageImportPath?.path).toBe(
path.resolve(cwd, "src/components/button.tsx")
)
})
})
describe("resolve workspace package exports", () => {
const root = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const cwd = path.resolve(root, "apps/web")
const config = {
absoluteBaseUrl: cwd,
paths: {},
cwd,
}
test("resolves workspace package wildcard exports for file imports", async () => {
const result = await resolveImportWithMetadata(
"@workspace/ui/components/button",
config
)
expect(result).toEqual({
path: path.resolve(root, "packages/ui/src/components/button.tsx"),
source: "workspace_package_exports",
matchedAlias: "@workspace/ui/components/*",
matchedTarget: "./src/components/*.tsx",
emitMode: "strip_extension",
})
})
test("resolves bare alias roots from workspace package wildcard exports", async () => {
expect(await resolveImport("@workspace/ui/components", config)).toEqual(
path.resolve(root, "packages/ui/src/components")
)
expect(await resolveImport("@workspace/ui/lib/utils", config)).toEqual(
path.resolve(root, "packages/ui/src/lib/utils.ts")
)
})
test("does not treat workspace package exports as local alias imports", () => {
expect(isLocalAliasImport("@workspace/ui/components/button", "#")).toBe(
false
)
})
})

View File

@@ -2,8 +2,7 @@ import { expect, test } from "vitest"
import { transform } from "../../src/utils/transformers"
test('transform nested workspace folder for utils, website/src/utils', async () => {
test("transform nested workspace folder for utils, website/src/utils", async () => {
expect(
await transform({
filename: "test.ts",
@@ -31,9 +30,50 @@ test('transform nested workspace folder for utils, website/src/utils', async ()
import { cn } from "website/src/utils"
"
`)
})
test.each([
{
name: "bare aliases",
aliases: {
components: "components",
ui: "components/ui",
lib: "lib",
utils: "lib/utils",
},
buttonImport: `import { Button } from "components/ui/button"`,
utilsImport: `import { cn } from "lib/utils"`,
},
{
name: "path-like aliases",
aliases: {
components: "website/src/components",
ui: "website/src/components/ui",
lib: "website/src/lib",
utils: "website/src/lib/utils",
},
buttonImport: `import { Button } from "website/src/components/ui/button"`,
utilsImport: `import { cn } from "website/src/lib/utils"`,
},
])(
"transform import with non-sigil aliases: $name",
async ({ aliases, buttonImport, utilsImport }) => {
const result = await transform({
filename: "test.ts",
raw: `import { Button } from "@/registry/new-york/ui/button"
import { cn } from "@/lib/utils"
`,
config: {
tsx: true,
aliases,
},
})
expect(result).toContain(buttonImport)
expect(result).toContain(utilsImport)
}
)
test("transform import", async () => {
expect(
await transform({
@@ -176,6 +216,76 @@ import { Foo } from "bar"
).toMatchSnapshot()
})
test("transform import with configured package-import aliases", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#app/components/ui/button"
import { cn } from "#app/lib/utils"
`,
config: {
tsx: true,
aliases: {
components: "#app/components",
ui: "#app/components/ui",
lib: "#app/lib",
utils: "#app/lib/utils",
},
},
})
).toMatchInlineSnapshot(`
"import { Button } from "#app/components/ui/button"
import { cn } from "#app/lib/utils"
"
`)
})
test("transform import keeps exact #utils aliases", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { cn } from "@/lib/utils"
`,
config: {
tsx: true,
aliases: {
components: "#components",
utils: "#utils",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toMatchInlineSnapshot(`
"import { cn } from "#utils"
"
`)
})
test("transform import keeps #lib/utils aliases", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { cn } from "@/lib/utils"
`,
config: {
tsx: true,
aliases: {
components: "#components",
utils: "#lib/utils",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toMatchInlineSnapshot(`
"import { cn } from "#lib/utils"
"
`)
})
test("transform import for monorepo", async () => {
expect(
await transform({
@@ -228,6 +338,160 @@ import { Foo } from "bar"
).toMatchSnapshot()
})
test("transform package import aliases and #registry placeholders", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#registry/new-york/ui/button"
import { Card } from "#/registry/new-york/ui/card"
import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
import { cn } from "#utils"
import { helper } from "#lib/helpers"
import { useThing } from "#hooks/use-thing"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import { Button } from "#components/ui/button"`)
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#registry/new-york/ui/button"
import { Card } from "#/registry/new-york/ui/card"
import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
import { cn } from "#utils"
import { helper } from "#lib/helpers"
import { useThing } from "#hooks/use-thing"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import { Card } from "#components/ui/card"`)
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#registry/new-york/ui/button"
import { Card } from "#/registry/new-york/ui/card"
import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
import { cn } from "#utils"
import { helper } from "#lib/helpers"
import { useThing } from "#hooks/use-thing"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import { cn } from "#utils"`)
expect(
await transform({
filename: "test.ts",
raw: `import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import * as RegistryRoot from "#components"`)
expect(
await transform({
filename: "test.ts",
raw: `import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import * as RegistryRootCompat from "#components"`)
})
test("prefers explicit workspace utils alias over local lib alias", async () => {
expect(
await transform({
filename: "test.tsx",
raw: `import { cn } from "@/lib/utils"
import { helper } from "@/lib/helper"
`,
config: {
tsx: true,
aliases: {
components: "#components",
lib: "#lib",
hooks: "#hooks",
ui: "@workspace/ui/components",
utils: "@workspace/ui/lib/utils",
},
},
})
).toContain(`import { cn } from "@workspace/ui/lib/utils"`)
})
test("prefers explicit utils alias for registry lib utils imports", async () => {
expect(
await transform({
filename: "login-form.tsx",
raw: `import { cn } from "@/registry/new-york-v4/lib/utils"
import { Button } from "@/registry/new-york-v4/ui/button"
`,
config: {
tsx: true,
aliases: {
components: "#components",
lib: "#lib",
hooks: "#hooks",
ui: "@workspace/ui/components",
utils: "@workspace/ui/lib/utils",
},
},
})
).toContain(`import { cn } from "@workspace/ui/lib/utils"`)
})
test("transform async/dynamic imports", async () => {
expect(
await transform({
@@ -324,6 +588,64 @@ async function loadMultiple() {
).toMatchSnapshot()
})
test("does not rewrite foreign scoped package imports when project uses # aliases", async () => {
const result = await transform({
filename: "test.tsx",
raw: `import { Analytics } from "@vercel/analytics/react"
import posthog from "posthog-js"
import { motion } from "motion/react"
import { Button } from "@/registry/new-york-v4/ui/button"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
expect(result).toContain(
`import { Analytics } from "@vercel/analytics/react"`
)
expect(result).toContain(`import posthog from "posthog-js"`)
expect(result).toContain(`import { motion } from "motion/react"`)
expect(result).toContain(`import { Button } from "#components/ui/button"`)
})
test("does not rewrite workspace package exports when project uses # aliases", async () => {
const result = await transform({
filename: "test.tsx",
raw: `import { Card } from "@workspace/ui/components/card"
import { useTheme } from "@workspace/ui/hooks/use-theme"
import { Button } from "@/registry/new-york-v4/ui/button"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "@workspace/ui/components",
utils: "@workspace/ui/lib/utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
expect(result).toContain(
`import { Card } from "@workspace/ui/components/card"`
)
expect(result).toContain(
`import { useTheme } from "@workspace/ui/hooks/use-theme"`
)
expect(result).toContain(
`import { Button } from "@workspace/ui/components/button"`
)
})
test("transform re-exports with dynamic imports", async () => {
expect(
await transform({

View File

@@ -1,6 +1,8 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterAll, afterEach, describe, expect, test, vi } from "vitest"
import prompts from "prompts"
import { Project } from "ts-morph"
import { getConfig } from "../../../src/utils/get-config"
import {
@@ -8,6 +10,7 @@ import {
resolveFilePath,
resolveModuleByProbablePath,
resolveNestedFilePath,
rewriteResolvedImportsInContent,
toAliasedImport,
updateFiles,
} from "../../../src/utils/updaters/update-files"
@@ -1073,6 +1076,330 @@ return <div>Hello World</div>
`)
})
test("should rewrite exact package-import subpaths to valid relative imports", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-package-import-exact-hook"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const fsModuleActual = (await vi.importActual("fs")) as typeof import("fs")
const writeFileMock = fs.writeFile as any
try {
writeFileMock.mockImplementation(fsModuleActual.promises.writeFile as any)
await fsActual.rm(tempDir, { recursive: true, force: true })
await fsActual.mkdir(path.join(tempDir, "src", "app"), { recursive: true })
await fsActual.mkdir(path.join(tempDir, "src", "hooks"), {
recursive: true,
})
await fsActual.mkdir(path.join(tempDir, "src", "lib"), { recursive: true })
await fsActual.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(
{
name: "temp-package-import-exact-hook",
type: "module",
imports: {
"#components/*": "./src/components/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
resolvePackageJsonImports: true,
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(
{
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "",
css: "src/app/globals.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
hooks: "#hooks",
utils: "#utils",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "app", "globals.css"),
'@import "tailwindcss";\n',
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "hooks", "index.ts"),
'export * from "./use-thing"\n',
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "lib", "utils.ts"),
"export function cn() {}\n",
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
await updateFiles(
[
{
path: "components/example-card.tsx",
type: "registry:component",
content: `import { useThing } from "@/hooks/use-thing"
export function ExampleCard() {
useThing()
return null
}
`,
},
{
path: "hooks/use-thing.ts",
type: "registry:hook",
content: `export function useThing() {
return true
}
`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
const componentContents = await fsActual.readFile(
path.join(tempDir, "src", "components", "example-card.tsx"),
"utf-8"
)
expect(componentContents).toContain(`from "../hooks/use-thing"`)
expect(componentContents).not.toContain(`from "#hooks/use-thing"`)
} finally {
writeFileMock.mockResolvedValue(undefined)
await fsActual.rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
})
test("should skip existing package-import files when final content is identical", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-package-import-same-content"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const fsModuleActual = (await vi.importActual("fs")) as typeof import("fs")
const writeFileMock = fs.writeFile as any
try {
writeFileMock.mockImplementation(fsModuleActual.promises.writeFile as any)
await fsActual.rm(tempDir, { recursive: true, force: true })
await fsActual.mkdir(path.join(tempDir, "src", "components", "ui"), {
recursive: true,
})
await fsActual.mkdir(path.join(tempDir, "src", "lib"), {
recursive: true,
})
await fsActual.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(
{
name: "temp-package-import-same-content",
type: "module",
imports: {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(
{
files: [],
references: [{ path: "./tsconfig.app.json" }],
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "tsconfig.app.json"),
JSON.stringify(
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
baseUrl: ".",
paths: {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"],
},
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(
{
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "index.css"),
'@import "tailwindcss";\n',
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "lib", "utils.ts"),
"export function cn(...inputs: unknown[]) {\n return inputs\n}\n",
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
const buttonFile = {
path: "registry/default/ui/button.tsx",
type: "registry:ui" as const,
content: `import { cn } from "@/lib/utils"
export function Button() {
return <button>{cn("button")}</button>
}
`,
}
await updateFiles([buttonFile], config, {
overwrite: true,
silent: true,
})
vi.mocked(prompts).mockClear()
const result = await updateFiles([buttonFile], config, {
overwrite: false,
silent: true,
})
expect(result.filesSkipped).toEqual(["src/components/ui/button.tsx"])
expect(result.filesUpdated).toEqual([])
expect(vi.mocked(prompts)).not.toHaveBeenCalled()
} finally {
writeFileMock.mockResolvedValue(undefined)
await fsActual.rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
})
test("should remove temporary source files after rewriting content", async () => {
const project = new Project({
compilerOptions: {},
})
const content = "export const value = 1\n"
await expect(
rewriteResolvedImportsInContent({
content,
resolvedPath: "/tmp/example.ts",
filePaths: [],
config: {
aliases: {},
resolvedPaths: {
cwd: "/tmp",
},
} as any,
projectInfo: {
aliasPrefix: "#",
} as any,
tsConfig: {
resultType: "success",
absoluteBaseUrl: "/tmp",
paths: {},
} as any,
project,
})
).resolves.toBe(content)
expect(project.getSourceFiles()).toHaveLength(0)
})
test("should mark .env file as created when it doesn't exist", async () => {
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
@@ -1099,6 +1426,48 @@ ANOTHER_NEW_KEY=another_value`,
expect(result.filesUpdated).not.toContain(".env")
})
test("should rewrite app-local files to workspace utils aliases in monorepos without tsconfig paths", async () => {
const config = await getConfig(
path.resolve(
__dirname,
"../../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
)
if (!config) {
throw new Error("Failed to get monorepo app config")
}
const result = await updateFiles(
[
{
path: "registry/components/login-form.tsx",
type: "registry:component",
content: `import { cn } from "@/lib/utils"
export function LoginForm() {
return <div>{cn("login")}</div>
}
`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
expect(result.filesCreated).toContain("src/components/login-form.tsx")
const writtenContent = (fs.writeFile as any).mock.calls.find((call: any) =>
call[0].endsWith("src/components/login-form.tsx")
)?.[1]
expect(writtenContent).toContain(`from "@workspace/ui/lib/utils"`)
expect(writtenContent).not.toContain(`from "#lib/utils"`)
})
test("should mark .env file as updated when merging content", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
@@ -1968,4 +2337,96 @@ describe("toAliasedImport", () => {
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home")
})
test("should preserve extensions for package imports that target bare wildcards", () => {
const filePath = "src/components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
components: path.resolve(
__dirname,
"../../fixtures/config-imports/src/components"
),
ui: path.resolve(
__dirname,
"../../fixtures/config-imports/src/components/ui"
),
},
aliases: {
components: "#components",
ui: "#components/ui",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#components/ui/button.tsx"
)
})
test("should strip extensions for package imports whose target already includes them", () => {
const filePath = "src/components/button.tsx"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/with-package-imports"),
components: path.resolve(
__dirname,
"../../fixtures/with-package-imports/src/components"
),
},
aliases: {
components: "#components-ext",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#components-ext/button"
)
})
test("should keep exact package import aliases for index files", () => {
const filePath = "src/hooks/index.ts"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
hooks: path.resolve(__dirname, "../../fixtures/config-imports/src/hooks"),
},
aliases: {
hooks: "#hooks",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("#hooks")
})
test("should prefer exact package import aliases over parent directory aliases", () => {
const filePath = "src/lib/utils.ts"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
lib: path.resolve(__dirname, "../../fixtures/config-imports/src/lib"),
utils: path.resolve(
__dirname,
"../../fixtures/config-imports/src/lib/utils.ts"
),
},
aliases: {
lib: "#lib",
utils: "#utils",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("#utils")
})
})

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "#components",
"utils": "#utils",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View File

@@ -0,0 +1,38 @@
{
"name": "next-app-imports",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"imports": {
"#components/*": "./src/components/*.tsx",
"#components/ui/*": "./src/components/ui/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.527.0",
"next": "15.4.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.6",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
}
export default config

View File

@@ -0,0 +1,123 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,22 @@
import "./globals.css"
import type { Metadata } from "next"
import { Inter } from "next/font/google"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
export default function Home() {
return <main>Hello</main>
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1 @@
console.log("web")

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,5 @@
{
"name": "vite-monorepo-imports",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@workspace/ui",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}
}

View File

@@ -0,0 +1,3 @@
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ")
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

View File

@@ -8,10 +8,24 @@ import {
getRegistryUrl,
npxShadcn,
} from "../utils/helpers"
import { configureRegistries, createRegistryServer } from "../utils/registry"
// Note: The tests here intentionally do not use a mocked registry.
// We test this against the real registry.
function expectCommandSuccess(result: Awaited<ReturnType<typeof npxShadcn>>) {
expect(
result.exitCode,
[
`Expected command to exit with 0, got ${result.exitCode}.`,
"stdout:",
result.stdout || "<empty>",
"stderr:",
result.stderr || "<empty>",
].join("\n")
).toBe(0)
}
describe("shadcn add", () => {
it("should add item to project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
@@ -166,6 +180,39 @@ describe("shadcn add", () => {
).toBe("Foo Bar")
})
it("should preview add changes without writing files", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
const result = await npxShadcn(fixturePath, ["add", "button", "--dry-run"])
expectCommandSuccess(result)
expect(result.stdout).toContain("shadcn add button (dry run)")
expect(result.stdout).toContain("components/ui/button.tsx")
expect(result.stdout).toContain("Run without --dry-run to apply.")
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(false)
})
it("should show no changes for identical files with diff", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await npxShadcn(fixturePath, ["add", "button", "--yes"])
const result = await npxShadcn(fixturePath, [
"add",
"button",
"--diff",
"button",
"--yes",
])
expectCommandSuccess(result)
expect(result.stdout).toContain("shadcn add button (dry run)")
expect(result.stdout).toContain("components/ui/button.tsx (skip)")
expect(result.stdout).toContain("No changes.")
})
it("should add item with target to src", async () => {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
@@ -182,12 +229,12 @@ describe("shadcn add", () => {
})
it("should add item with target to root", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
await npxShadcn(fixturePath, [
const fixturePath = await createFixtureTestDirectory("next-app-init")
const result = await npxShadcn(fixturePath, [
"add",
"../../fixtures/registry/example-item-to-root.json",
])
expectCommandSuccess(result)
expect(await fs.pathExists(path.join(fixturePath, "config.json"))).toBe(
true
)
@@ -230,6 +277,106 @@ describe("shadcn add", () => {
`)
})
it("should add monorepo components and rewrite app-local imports with package imports", async () => {
const fixturePath = await createFixtureTestDirectory(
"vite-monorepo-imports"
)
const result = await npxShadcn(
fixturePath,
["add", "login-03", "-c", "apps/web", "--yes"],
{ timeout: 300000 }
)
expectCommandSuccess(result)
expect(
await fs.pathExists(
path.join(fixturePath, "apps/web/src/components/login-form.tsx")
)
).toBe(true)
expect(
await fs.pathExists(
path.join(fixturePath, "packages/ui/src/components/button.tsx")
)
).toBe(true)
expect(
await fs.pathExists(
path.join(fixturePath, "apps/web/src/components/ui/button.tsx")
)
).toBe(false)
const loginFormContent = await fs.readFile(
path.join(fixturePath, "apps/web/src/components/login-form.tsx"),
"utf-8"
)
expect(loginFormContent).toContain(
'import { cn } from "@workspace/ui/lib/utils"'
)
expect(loginFormContent).toContain(
'import { Button } from "@workspace/ui/components/button"'
)
const buttonContent = await fs.readFile(
path.join(fixturePath, "packages/ui/src/components/button.tsx"),
"utf-8"
)
expect(buttonContent).toContain('import { cn } from "#lib/utils.ts"')
}, 300000)
it("should preview monorepo adds without writing files", async () => {
const fixturePath = await createFixtureTestDirectory(
"vite-monorepo-imports"
)
const result = await npxShadcn(
fixturePath,
["add", "login-03", "-c", "apps/web", "--dry-run", "--yes"],
{ timeout: 300000 }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("shadcn add login-03 (dry run)")
expect(result.stdout).toContain(
"../../packages/ui/src/components/button.tsx"
)
expect(result.stdout).toContain("src/components/login-form.tsx")
expect(
await fs.pathExists(
path.join(fixturePath, "apps/web/src/components/login-form.tsx")
)
).toBe(false)
expect(
await fs.pathExists(
path.join(fixturePath, "packages/ui/src/components/button.tsx")
)
).toBe(false)
}, 300000)
it("should show no changes for identical monorepo files with diff", async () => {
const fixturePath = await createFixtureTestDirectory(
"vite-monorepo-imports"
)
const setupResult = await npxShadcn(
fixturePath,
["add", "login-03", "-c", "apps/web", "--yes"],
{ timeout: 300000 }
)
expectCommandSuccess(setupResult)
const result = await npxShadcn(
fixturePath,
["add", "login-03", "-c", "apps/web", "--diff", "login-form", "--yes"],
{ timeout: 300000 }
)
expectCommandSuccess(result)
expect(result.stdout).toContain("shadcn add login-03 (dry run)")
expect(result.stdout).toContain("src/components/login-form.tsx (skip)")
expect(result.stdout).toContain("No changes.")
}, 300000)
it("should add NOT update existing envVars", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
@@ -342,6 +489,146 @@ describe("shadcn add", () => {
).toBe(false)
})
it("should add component to a single-package #imports project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-imports")
const result = await npxShadcn(fixturePath, ["add", "button", "--yes"])
expectCommandSuccess(result)
const buttonPath = path.join(fixturePath, "src/components/ui/button.tsx")
expect(await fs.pathExists(buttonPath)).toBe(true)
const buttonContent = await fs.readFile(buttonPath, "utf-8")
expect(buttonContent).toContain('import { cn } from "#utils"')
expect(buttonContent).not.toContain("@/lib/utils")
expect(buttonContent).not.toContain("@/registry/")
})
it("should add multi-file block to a single-package #imports project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-imports")
const result = await npxShadcn(fixturePath, ["add", "login-03", "--yes"])
expectCommandSuccess(result)
const loginFormPath = path.join(
fixturePath,
"src/components/login-form.tsx"
)
const buttonPath = path.join(fixturePath, "src/components/ui/button.tsx")
expect(await fs.pathExists(loginFormPath)).toBe(true)
expect(await fs.pathExists(buttonPath)).toBe(true)
const loginFormContent = await fs.readFile(loginFormPath, "utf-8")
expect(loginFormContent).toContain('import { cn } from "#utils"')
expect(loginFormContent).toContain(
'import { Button } from "#components/ui/button"'
)
expect(loginFormContent).not.toContain("@/registry/")
})
it("should preview --dry-run for a single-package #imports project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-imports")
const result = await npxShadcn(fixturePath, [
"add",
"button",
"--dry-run",
"--yes",
])
expectCommandSuccess(result)
expect(result.stdout).toContain("shadcn add button (dry run)")
expect(result.stdout).toContain("src/components/ui/button.tsx")
expect(result.stdout).toContain("Run without --dry-run to apply.")
expect(
await fs.pathExists(
path.join(fixturePath, "src/components/ui/button.tsx")
)
).toBe(false)
})
it("should show --diff no-op for identical content in a #imports project", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-imports")
const setup = await npxShadcn(fixturePath, ["add", "button", "--yes"])
expectCommandSuccess(setup)
const result = await npxShadcn(fixturePath, [
"add",
"button",
"--diff",
"button",
"--yes",
])
expectCommandSuccess(result)
expect(result.stdout).toContain("shadcn add button (dry run)")
expect(result.stdout).toContain("src/components/ui/button.tsx (skip)")
expect(result.stdout).toContain("No changes.")
})
it("should add namespaced registry item to a #imports project", async () => {
const registry = await createRegistryServer(
[
{
name: "fancy-card",
type: "registry:component",
registryDependencies: ["button"],
files: [
{
path: "components/fancy-card.tsx",
type: "registry:component",
content: `import { Button } from "@/registry/new-york-v4/ui/button"
import { cn } from "@/lib/utils"
export function FancyCard() {
return <Button className={cn("rounded-lg")}>Fancy</Button>
}
`,
},
],
},
],
{
port: 4454,
}
)
await registry.start()
try {
const fixturePath = await createFixtureTestDirectory("next-app-imports")
await configureRegistries(fixturePath, {
"@one": "http://localhost:4454/r/{name}",
})
const result = await npxShadcn(fixturePath, [
"add",
"@one/fancy-card",
"--yes",
])
expectCommandSuccess(result)
const cardPath = path.join(fixturePath, "src/components/fancy-card.tsx")
const buttonPath = path.join(fixturePath, "src/components/ui/button.tsx")
expect(await fs.pathExists(cardPath)).toBe(true)
expect(await fs.pathExists(buttonPath)).toBe(true)
const cardContent = await fs.readFile(cardPath, "utf-8")
expect(cardContent).toContain(
'import { Button } from "#components/ui/button"'
)
expect(cardContent).toContain('import { cn } from "#utils"')
expect(cardContent).not.toContain("@/registry/")
expect(cardContent).not.toContain("@/lib/utils")
} finally {
await registry.stop()
}
})
it("should add at-property", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await npxShadcn(fixturePath, [