feat: add shadcn eject (#10834)

This commit is contained in:
shadcn
2026-05-31 16:11:01 +04:00
committed by GitHub
parent 67cef8fcb9
commit 8e2d2d1439
7 changed files with 665 additions and 1 deletions

View File

@@ -1,4 +1,4 @@
# @shadcn/ui
# shadcn
## 4.8.3

View File

@@ -0,0 +1,262 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"
import os from "os"
import path from "path"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { execa } from "execa"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { runEject, SHADCN_TAILWIND_IMPORT } from "./eject"
vi.mock("execa", () => ({
execa: vi.fn(),
}))
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageManager: vi.fn().mockResolvedValue("pnpm"),
}))
vi.mock("@/src/utils/get-monorepo-info", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/utils/get-monorepo-info")>()
return {
...actual,
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn().mockResolvedValue([]),
isMonorepoRoot: vi.fn().mockResolvedValue(false),
}
})
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
const fixtureConfig = {
$schema: "https://ui.shadcn.com/schema.json",
style: "base-nova",
rsc: true,
tsx: true,
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
}
const fixtureTsconfig = {
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./*"],
},
},
}
const baseCss = `@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@layer base {
body {
@apply bg-background text-foreground;
}
}
`
describe("runEject", () => {
let tempDir: string
beforeEach(async () => {
vi.clearAllMocks()
vi.mocked(getPackageManager).mockResolvedValue("pnpm")
tempDir = await mkdtemp(path.join(os.tmpdir(), "shadcn-eject-"))
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
async function setupProject(
cssContent: string,
packageJson: Record<string, unknown> = {
name: "test-app",
dependencies: {
shadcn: "^4.8.3",
},
}
) {
await writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(fixtureConfig, null, 2)
)
await writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(fixtureTsconfig, null, 2)
)
await mkdir(path.join(tempDir, "app"), { recursive: true })
await writeFile(path.join(tempDir, "app", "globals.css"), cssContent)
await writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(packageJson, null, 2)
)
}
it("inlines shadcn/tailwind.css and removes the dependency", async () => {
await setupProject(baseCss)
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const output = await readFile(
path.join(tempDir, "app", "globals.css"),
"utf8"
)
const shadcnCss = await readFile(
path.join(process.cwd(), "src/tailwind.css"),
"utf8"
)
expect(output).not.toMatch(SHADCN_TAILWIND_IMPORT)
expect(output).toContain('@import "tailwindcss";')
expect(output).toContain('@import "tw-animate-css";')
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
expect(output).toContain(shadcnCss.trim())
expect(output).toContain("@layer base")
expect(output).toContain("@apply bg-background text-foreground;")
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
cwd: tempDir,
})
})
it("removes shadcn from devDependencies", async () => {
await setupProject(baseCss, {
name: "test-app",
devDependencies: {
shadcn: "^4.8.3",
},
})
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const output = await readFile(
path.join(tempDir, "app", "globals.css"),
"utf8"
)
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
cwd: tempDir,
})
})
it("skips dependency removal when shadcn is not in package.json", async () => {
await setupProject(baseCss, {
name: "test-app",
dependencies: {},
})
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
expect(execa).not.toHaveBeenCalled()
})
it("removes shadcn from package.json for deno", async () => {
vi.mocked(getPackageManager).mockResolvedValue("deno")
await setupProject(baseCss)
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const packageJson = JSON.parse(
await readFile(path.join(tempDir, "package.json"), "utf8")
)
expect(packageJson.dependencies?.shadcn).toBeUndefined()
expect(execa).not.toHaveBeenCalled()
})
it("exits when shadcn/tailwind.css is not imported", async () => {
await setupProject(`@import "tailwindcss";
@import "tw-animate-css";
`)
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit")
}) as never)
await expect(
runEject({
cwd: tempDir,
yes: true,
silent: true,
})
).rejects.toThrow("process.exit")
exit.mockRestore()
})
it("shows monorepo suggestions when run from a monorepo root", async () => {
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue([
{ name: "packages/ui", hasConfig: true },
{ name: "apps/web", hasConfig: true },
])
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit")
}) as never)
await expect(
runEject({
cwd: tempDir,
yes: true,
silent: true,
})
).rejects.toThrow("process.exit")
expect(formatMonorepoMessage).toHaveBeenCalledWith("eject", [
{ name: "packages/ui", hasConfig: true },
{ name: "apps/web", hasConfig: true },
])
exit.mockRestore()
})
})

View File

@@ -0,0 +1,248 @@
import { promises as fs } from "fs"
import path from "path"
import { SHADCN_URL } from "@/src/registry/constants"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getPackageInfo } from "@/src/utils/get-package-info"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { execa } from "execa"
import fsExtra from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
export const SHADCN_TAILWIND_IMPORT =
/@import\s+["']shadcn\/tailwind\.css["'];?\s*\n?/
export const ejectOptionsSchema = z.object({
cwd: z.string(),
yes: z.boolean(),
silent: z.boolean(),
})
export const eject = new Command()
.name("eject")
.description("inline shadcn/tailwind.css and remove the shadcn dependency")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-s, --silent", "mute output.", false)
.action(async (opts) => {
try {
const options = ejectOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
yes: opts.yes,
silent: opts.silent,
})
await runEject(options)
} catch (error) {
handleError(error)
}
})
export async function runEject(options: z.infer<typeof ejectOptionsSchema>) {
if (!fsExtra.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("eject", targets)
process.exit(1)
}
}
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found. Run ${highlighter.info("shadcn init")} first.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
const config = await getConfig(options.cwd)
if (!config?.resolvedPaths.tailwindCss) {
logger.break()
logger.error(
"Could not resolve the Tailwind CSS file from components.json."
)
logger.break()
process.exit(1)
}
const cssFilepath = config.resolvedPaths.tailwindCss
const cssFilepathRelative = path.relative(options.cwd, cssFilepath)
let cssContent = await fs.readFile(cssFilepath, "utf8")
if (!SHADCN_TAILWIND_IMPORT.test(cssContent)) {
logger.break()
logger.error(
`Could not find ${highlighter.info('@import "shadcn/tailwind.css"')} in ${highlighter.info(cssFilepathRelative)}.`
)
logger.error("Nothing to eject.")
logger.break()
process.exit(1)
}
const packageInfo = getPackageInfo(options.cwd, false)
const shadcnVersion = getShadcnVersion(packageInfo)
const shadcnCssPath = resolveShadcnTailwindCss(options.cwd)
const shadcnCssContent = await fs.readFile(shadcnCssPath, "utf8")
if (!options.silent) {
logger.break()
logger.warn(
"This action is not reversible. Future shadcn CLI updates to tailwind.css will not apply automatically."
)
logger.break()
}
if (!options.yes) {
logger.log("This will:")
logger.log(
` - Inline ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}`
)
logger.log(` - Remove the ${highlighter.info("shadcn")} dependency`)
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Proceed?",
initial: false,
})
if (!proceed) {
process.exit(0)
}
}
const ejectSpinner = spinner(
`Inlining ${highlighter.info("shadcn/tailwind.css")}.`,
{
silent: options.silent,
}
)?.start()
cssContent = cssContent.replace(
SHADCN_TAILWIND_IMPORT,
() =>
`/* ejected from shadcn@${shadcnVersion} */\n${shadcnCssContent.trim()}\n\n`
)
await fs.writeFile(cssFilepath, cssContent, "utf8")
ejectSpinner?.succeed()
if (hasShadcnDependency(packageInfo)) {
const removeSpinner = spinner(`Removing ${highlighter.info("shadcn")}.`, {
silent: options.silent,
})?.start()
await removeShadcnDependency(options.cwd)
removeSpinner?.succeed()
} else if (!options.silent) {
logger.warn(
`The ${highlighter.info("shadcn")} package was not found in package.json. Skipped removal.`
)
}
logger.break()
logger.log(
`Ejected ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}.`
)
logger.break()
}
function getShadcnVersion(packageInfo: ReturnType<typeof getPackageInfo>) {
if (!packageInfo) {
return "unknown"
}
return (
packageInfo.dependencies?.shadcn ??
packageInfo.devDependencies?.shadcn ??
"unknown"
)
.replace(/^[\^~]/, "")
.trim()
}
function hasShadcnDependency(packageInfo: ReturnType<typeof getPackageInfo>) {
if (!packageInfo) {
return false
}
return Boolean(
packageInfo.dependencies?.shadcn || packageInfo.devDependencies?.shadcn
)
}
function resolveShadcnTailwindCss(cwd: string) {
const projectCss = path.join(cwd, "node_modules/shadcn/dist/tailwind.css")
if (fsExtra.existsSync(projectCss)) {
return projectCss
}
const cliRoot = process.argv[1]
? path.dirname(path.resolve(process.argv[1]))
: cwd
for (const candidate of [
path.join(cliRoot, "tailwind.css"),
path.join(cliRoot, "dist", "tailwind.css"),
path.join(cliRoot, "src", "tailwind.css"),
path.join(process.cwd(), "src/tailwind.css"),
path.join(process.cwd(), "dist/tailwind.css"),
]) {
if (fsExtra.existsSync(candidate)) {
return candidate
}
}
throw new Error("Could not resolve shadcn/tailwind.css.")
}
async function removeShadcnDependency(cwd: string) {
const packageManager = await getPackageManager(cwd)
switch (packageManager) {
case "npm":
await execa("npm", ["uninstall", "shadcn"], { cwd })
break
case "pnpm":
await execa("pnpm", ["remove", "shadcn"], { cwd })
break
case "yarn":
await execa("yarn", ["remove", "shadcn"], { cwd })
break
case "bun":
await execa("bun", ["remove", "shadcn"], { cwd })
break
case "deno": {
const packageJsonPath = path.join(cwd, "package.json")
const packageJson = await fsExtra.readJson(packageJsonPath)
for (const field of ["dependencies", "devDependencies"] as const) {
if (packageJson[field]?.shadcn) {
delete packageJson[field].shadcn
}
}
await fsExtra.writeJson(packageJsonPath, packageJson, { spaces: 2 })
break
}
}
}

View File

@@ -4,6 +4,7 @@ import { apply } from "@/src/commands/apply"
import { build } from "@/src/commands/build"
import { diff } from "@/src/commands/diff"
import { docs } from "@/src/commands/docs"
import { eject } from "@/src/commands/eject"
import { info } from "@/src/commands/info"
import { init } from "@/src/commands/init"
import { mcp } from "@/src/commands/mcp"
@@ -38,6 +39,7 @@ async function main() {
.addCommand(view)
.addCommand(search)
.addCommand(migrate)
.addCommand(eject)
.addCommand(info)
.addCommand(build)
.addCommand(mcp)