mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat: add shadcn eject (#10834)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# @shadcn/ui
|
||||
# shadcn
|
||||
|
||||
## 4.8.3
|
||||
|
||||
|
||||
262
packages/shadcn/src/commands/eject.test.ts
Normal file
262
packages/shadcn/src/commands/eject.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
248
packages/shadcn/src/commands/eject.ts
Normal file
248
packages/shadcn/src/commands/eject.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user