mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
feat: init
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import path from "path"
|
||||
import { getPreset, getPresets, getRegistryItems } from "@/src/registry/api"
|
||||
import { getRegistryItems } from "@/src/registry/api"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { isUrl } from "@/src/registry/utils"
|
||||
import { Preset } from "@/src/schema"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import {
|
||||
buildInitUrl,
|
||||
getShadcnCreateUrl,
|
||||
handlePresetOption,
|
||||
} from "@/src/utils/presets"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { updateFiles } from "@/src/utils/updaters/update-files"
|
||||
import { Command } from "commander"
|
||||
@@ -19,8 +21,6 @@ import validateProjectName from "validate-npm-package-name"
|
||||
|
||||
import { initOptionsSchema, runInit } from "./init"
|
||||
|
||||
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
|
||||
|
||||
const CREATE_TEMPLATES = {
|
||||
next: "Next.js",
|
||||
vite: "Vite",
|
||||
@@ -246,85 +246,6 @@ export const create = new Command()
|
||||
}
|
||||
})
|
||||
|
||||
function buildInitUrl(preset: Preset, rtl: boolean) {
|
||||
const params = new URLSearchParams({
|
||||
base: preset.base,
|
||||
style: preset.style,
|
||||
baseColor: preset.baseColor,
|
||||
theme: preset.theme,
|
||||
iconLibrary: preset.iconLibrary,
|
||||
font: preset.font,
|
||||
rtl: String(rtl || preset.rtl),
|
||||
menuAccent: preset.menuAccent,
|
||||
menuColor: preset.menuColor,
|
||||
radius: preset.radius,
|
||||
})
|
||||
|
||||
return `${getShadcnInitUrl()}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function handlePresetOption(presetArg: string | boolean, rtl: boolean) {
|
||||
// If --preset is used without a name, show interactive list.
|
||||
if (presetArg === true) {
|
||||
const presets = await getPresets()
|
||||
|
||||
const { selectedPreset } = await prompts({
|
||||
type: "select",
|
||||
name: "selectedPreset",
|
||||
message: `Which ${highlighter.info("preset")} would you like to use?`,
|
||||
choices: [
|
||||
...presets.map((preset) => ({
|
||||
title: preset.title,
|
||||
description: preset.description,
|
||||
value: preset.name,
|
||||
})),
|
||||
{
|
||||
title: "Custom",
|
||||
description: "Build your own on https://ui.shadcn.com",
|
||||
value: "custom",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!selectedPreset) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedPreset === "custom") {
|
||||
const url = getShadcnCreateUrl(rtl ? { rtl: "true" } : undefined)
|
||||
logger.info(`\nOpening ${highlighter.info(url)} in your browser...\n`)
|
||||
await open(url)
|
||||
return null
|
||||
}
|
||||
|
||||
return presets.find((p) => p.name === selectedPreset) ?? null
|
||||
}
|
||||
|
||||
// If --preset NAME or URL is provided.
|
||||
if (typeof presetArg === "string") {
|
||||
// Check if it's a URL.
|
||||
if (isUrl(presetArg)) {
|
||||
return { _isUrl: true, url: presetArg } as const
|
||||
}
|
||||
|
||||
// Otherwise, fetch that preset by name.
|
||||
const preset = await getPreset(presetArg)
|
||||
|
||||
if (!preset) {
|
||||
const presets = await getPresets()
|
||||
const presetNames = presets.map((p) => p.name).join(", ")
|
||||
logger.error(
|
||||
`Preset "${presetArg}" not found. Available presets: ${presetNames}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return preset
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getTemplateFiles(template: Template) {
|
||||
switch (template) {
|
||||
case "vite":
|
||||
@@ -380,17 +301,3 @@ function App() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getShadcnCreateUrl(searchParams?: Record<string, string>) {
|
||||
const url = new URL(`${SHADCN_URL}/create`)
|
||||
if (searchParams) {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function getShadcnInitUrl() {
|
||||
return `${SHADCN_URL}/init`
|
||||
}
|
||||
|
||||
@@ -39,12 +39,18 @@ import {
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import {
|
||||
buildInitUrl,
|
||||
getShadcnCreateUrl,
|
||||
handlePresetOption,
|
||||
} from "@/src/utils/presets"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { updateTailwindContent } from "@/src/utils/updaters/update-tailwind-content"
|
||||
import { Command } from "commander"
|
||||
import deepmerge from "deepmerge"
|
||||
import fsExtra from "fs-extra"
|
||||
import open from "open"
|
||||
import prompts from "prompts"
|
||||
import { z } from "zod"
|
||||
|
||||
@@ -144,6 +150,7 @@ export const init = new Command()
|
||||
.option("--no-css-variables", "do not use css variables for theming.")
|
||||
.option("--no-base-style", "do not install the base shadcn style.")
|
||||
.option("--rtl", "enable RTL support.", false)
|
||||
.option("-p, --preset [name]", "use a preset configuration")
|
||||
.action(async (components, opts) => {
|
||||
try {
|
||||
// Apply defaults when --defaults flag is set.
|
||||
@@ -152,6 +159,72 @@ export const init = new Command()
|
||||
opts.baseColor = opts.baseColor || "neutral"
|
||||
}
|
||||
|
||||
// Handle --preset option.
|
||||
if (opts.preset !== undefined) {
|
||||
const presetResult = await handlePresetOption(
|
||||
opts.preset === true ? true : opts.preset,
|
||||
opts.rtl ?? false
|
||||
)
|
||||
|
||||
if (!presetResult) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let initUrl: string
|
||||
let baseColor: string
|
||||
|
||||
if ("_isUrl" in presetResult) {
|
||||
const url = new URL(presetResult.url)
|
||||
if (opts.rtl) {
|
||||
url.searchParams.set("rtl", "true")
|
||||
}
|
||||
initUrl = url.toString()
|
||||
baseColor = url.searchParams.get("baseColor") ?? "neutral"
|
||||
} else {
|
||||
initUrl = buildInitUrl(presetResult, opts.rtl ?? false)
|
||||
baseColor = presetResult.baseColor
|
||||
}
|
||||
|
||||
// Prepend the preset URL to the components list.
|
||||
components = [initUrl, ...components]
|
||||
|
||||
// Set baseColor from preset if not explicitly provided.
|
||||
if (!opts.baseColor) {
|
||||
opts.baseColor = baseColor
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt to use shadcn/create when no preset, no components, and no defaults.
|
||||
if (
|
||||
opts.preset === undefined &&
|
||||
components.length === 0 &&
|
||||
!opts.defaults
|
||||
) {
|
||||
const { useCreate } = await prompts({
|
||||
type: "confirm",
|
||||
name: "useCreate",
|
||||
message:
|
||||
"Would you like to use shadcn/create to build your custom design system?",
|
||||
initial: true,
|
||||
})
|
||||
|
||||
if (useCreate) {
|
||||
const createUrl = getShadcnCreateUrl(
|
||||
opts.rtl ? { rtl: "true" } : undefined
|
||||
)
|
||||
logger.info(
|
||||
`\nOpening ${highlighter.info(createUrl)} in your browser...\n`
|
||||
)
|
||||
await open(createUrl)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// User chose "No" — continue with default init flow.
|
||||
opts.defaults = true
|
||||
opts.template = opts.template || "next"
|
||||
opts.baseColor = opts.baseColor || "neutral"
|
||||
}
|
||||
|
||||
const options = initOptionsSchema.parse({
|
||||
cwd: path.resolve(opts.cwd),
|
||||
isNewProject: false,
|
||||
|
||||
180
packages/shadcn/src/utils/presets.test.ts
Normal file
180
packages/shadcn/src/utils/presets.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { getPreset, getPresets } from "@/src/registry/api"
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import open from "open"
|
||||
import prompts from "prompts"
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest"
|
||||
|
||||
import {
|
||||
buildInitUrl,
|
||||
getShadcnCreateUrl,
|
||||
getShadcnInitUrl,
|
||||
handlePresetOption,
|
||||
} from "./presets"
|
||||
|
||||
vi.mock("open")
|
||||
vi.mock("prompts")
|
||||
vi.mock("@/src/registry/api", () => ({
|
||||
getPreset: vi.fn(),
|
||||
getPresets: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
break: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock("@/src/utils/highlighter", () => ({
|
||||
highlighter: {
|
||||
info: vi.fn((s: string) => s),
|
||||
},
|
||||
}))
|
||||
|
||||
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
|
||||
|
||||
const mockPreset = {
|
||||
name: "default",
|
||||
title: "Default",
|
||||
description: "The default preset.",
|
||||
base: "radix",
|
||||
style: "new-york-v4",
|
||||
baseColor: "neutral",
|
||||
theme: "default",
|
||||
iconLibrary: "lucide",
|
||||
font: "inter",
|
||||
rtl: false,
|
||||
menuAccent: "subtle" as const,
|
||||
menuColor: "default" as const,
|
||||
radius: "0.5",
|
||||
}
|
||||
|
||||
describe("getShadcnInitUrl", () => {
|
||||
it("should return the init url", () => {
|
||||
expect(getShadcnInitUrl()).toBe(`${SHADCN_URL}/init`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getShadcnCreateUrl", () => {
|
||||
it("should return the create url with no params", () => {
|
||||
expect(getShadcnCreateUrl()).toBe(`${SHADCN_URL}/create`)
|
||||
})
|
||||
|
||||
it("should append search params when provided", () => {
|
||||
const url = getShadcnCreateUrl({ rtl: "true", template: "next" })
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
expect(parsed.searchParams.get("template")).toBe("next")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildInitUrl", () => {
|
||||
it("should build url with all preset fields as query params", () => {
|
||||
const url = buildInitUrl(mockPreset, false)
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
|
||||
expect(parsed.searchParams.get("base")).toBe("radix")
|
||||
expect(parsed.searchParams.get("style")).toBe("new-york-v4")
|
||||
expect(parsed.searchParams.get("baseColor")).toBe("neutral")
|
||||
expect(parsed.searchParams.get("theme")).toBe("default")
|
||||
expect(parsed.searchParams.get("iconLibrary")).toBe("lucide")
|
||||
expect(parsed.searchParams.get("font")).toBe("inter")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("false")
|
||||
expect(parsed.searchParams.get("menuAccent")).toBe("subtle")
|
||||
expect(parsed.searchParams.get("menuColor")).toBe("default")
|
||||
expect(parsed.searchParams.get("radius")).toBe("0.5")
|
||||
})
|
||||
|
||||
it("should set rtl=true when rtl arg is true", () => {
|
||||
const url = buildInitUrl(mockPreset, true)
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should set rtl=true when preset.rtl is true", () => {
|
||||
const url = buildInitUrl({ ...mockPreset, rtl: true }, false)
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("handlePresetOption", () => {
|
||||
let mockExit: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getPresets).mockResolvedValue([mockPreset])
|
||||
vi.mocked(getPreset).mockResolvedValue(mockPreset)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockExit?.mockRestore()
|
||||
})
|
||||
|
||||
it("should show interactive list when presetArg is true", async () => {
|
||||
vi.mocked(prompts).mockResolvedValue({ selectedPreset: "default" })
|
||||
|
||||
const result = await handlePresetOption(true, false)
|
||||
|
||||
expect(getPresets).toHaveBeenCalled()
|
||||
expect(prompts).toHaveBeenCalled()
|
||||
expect(result).toEqual(mockPreset)
|
||||
})
|
||||
|
||||
it("should return null when user cancels selection", async () => {
|
||||
vi.mocked(prompts).mockResolvedValue({ selectedPreset: undefined })
|
||||
|
||||
const result = await handlePresetOption(true, false)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should open browser and return null when custom is selected", async () => {
|
||||
vi.mocked(prompts).mockResolvedValue({ selectedPreset: "custom" })
|
||||
|
||||
const result = await handlePresetOption(true, false)
|
||||
|
||||
expect(open).toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should resolve a preset by name", async () => {
|
||||
const result = await handlePresetOption("default", false)
|
||||
|
||||
expect(getPreset).toHaveBeenCalledWith("default")
|
||||
expect(result).toEqual(mockPreset)
|
||||
})
|
||||
|
||||
it("should return url object when arg is a URL", async () => {
|
||||
const url = "https://ui.shadcn.com/init?base=radix"
|
||||
const result = await handlePresetOption(url, false)
|
||||
|
||||
expect(result).toEqual({ _isUrl: true, url })
|
||||
})
|
||||
|
||||
it("should call process.exit(1) when preset name not found", async () => {
|
||||
vi.mocked(getPreset).mockResolvedValue(null)
|
||||
mockExit = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never)
|
||||
|
||||
await handlePresetOption("nonexistent", false)
|
||||
|
||||
expect(mockExit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it("should return null when presetArg is false", async () => {
|
||||
const result = await handlePresetOption(false as unknown as boolean, false)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
106
packages/shadcn/src/utils/presets.ts
Normal file
106
packages/shadcn/src/utils/presets.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { getPreset, getPresets } from "@/src/registry/api"
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { isUrl } from "@/src/registry/utils"
|
||||
import { Preset } from "@/src/schema"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import open from "open"
|
||||
import prompts from "prompts"
|
||||
|
||||
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
|
||||
|
||||
export function getShadcnCreateUrl(searchParams?: Record<string, string>) {
|
||||
const url = new URL(`${SHADCN_URL}/create`)
|
||||
if (searchParams) {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function getShadcnInitUrl() {
|
||||
return `${SHADCN_URL}/init`
|
||||
}
|
||||
|
||||
export function buildInitUrl(preset: Preset, rtl: boolean) {
|
||||
const params = new URLSearchParams({
|
||||
base: preset.base,
|
||||
style: preset.style,
|
||||
baseColor: preset.baseColor,
|
||||
theme: preset.theme,
|
||||
iconLibrary: preset.iconLibrary,
|
||||
font: preset.font,
|
||||
rtl: String(rtl || preset.rtl),
|
||||
menuAccent: preset.menuAccent,
|
||||
menuColor: preset.menuColor,
|
||||
radius: preset.radius,
|
||||
})
|
||||
|
||||
return `${getShadcnInitUrl()}?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function handlePresetOption(
|
||||
presetArg: string | boolean,
|
||||
rtl: boolean
|
||||
) {
|
||||
// If --preset is used without a name, show interactive list.
|
||||
if (presetArg === true) {
|
||||
const presets = await getPresets()
|
||||
|
||||
const { selectedPreset } = await prompts({
|
||||
type: "select",
|
||||
name: "selectedPreset",
|
||||
message: `Which ${highlighter.info("preset")} would you like to use?`,
|
||||
choices: [
|
||||
...presets.map((preset) => ({
|
||||
title: preset.title,
|
||||
description: preset.description,
|
||||
value: preset.name,
|
||||
})),
|
||||
{
|
||||
title: "Custom",
|
||||
description: "Build your own on https://ui.shadcn.com",
|
||||
value: "custom",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!selectedPreset) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedPreset === "custom") {
|
||||
const url = getShadcnCreateUrl(rtl ? { rtl: "true" } : undefined)
|
||||
logger.info(`\nOpening ${highlighter.info(url)} in your browser...\n`)
|
||||
await open(url)
|
||||
return null
|
||||
}
|
||||
|
||||
return presets.find((p) => p.name === selectedPreset) ?? null
|
||||
}
|
||||
|
||||
// If --preset NAME or URL is provided.
|
||||
if (typeof presetArg === "string") {
|
||||
// Check if it's a URL.
|
||||
if (isUrl(presetArg)) {
|
||||
return { _isUrl: true, url: presetArg } as const
|
||||
}
|
||||
|
||||
// Otherwise, fetch that preset by name.
|
||||
const preset = await getPreset(presetArg)
|
||||
|
||||
if (!preset) {
|
||||
const presets = await getPresets()
|
||||
const presetNames = presets.map((p) => p.name).join(", ")
|
||||
logger.error(
|
||||
`Preset "${presetArg}" not found. Available presets: ${presetNames}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return preset
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user