feat: init

This commit is contained in:
shadcn
2026-02-10 09:48:17 +04:00
parent ae95fbd1be
commit 0137b07f66
4 changed files with 365 additions and 99 deletions

View File

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

View File

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

View 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()
})
})

View 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
}