From 0137b07f6691fe77aaf6d1be061280467067814d Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 10 Feb 2026 09:48:17 +0400 Subject: [PATCH] feat: init --- packages/shadcn/src/commands/create.ts | 105 +------------ packages/shadcn/src/commands/init.ts | 73 +++++++++ packages/shadcn/src/utils/presets.test.ts | 180 ++++++++++++++++++++++ packages/shadcn/src/utils/presets.ts | 106 +++++++++++++ 4 files changed, 365 insertions(+), 99 deletions(-) create mode 100644 packages/shadcn/src/utils/presets.test.ts create mode 100644 packages/shadcn/src/utils/presets.ts diff --git a/packages/shadcn/src/commands/create.ts b/packages/shadcn/src/commands/create.ts index 53027d951..8247040f8 100644 --- a/packages/shadcn/src/commands/create.ts +++ b/packages/shadcn/src/commands/create.ts @@ -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) { - 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` -} diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 9b12cd81f..3ab3e7a23 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -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, diff --git a/packages/shadcn/src/utils/presets.test.ts b/packages/shadcn/src/utils/presets.test.ts new file mode 100644 index 000000000..cf4ccb779 --- /dev/null +++ b/packages/shadcn/src/utils/presets.test.ts @@ -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() + }) +}) diff --git a/packages/shadcn/src/utils/presets.ts b/packages/shadcn/src/utils/presets.ts new file mode 100644 index 000000000..6dd09934e --- /dev/null +++ b/packages/shadcn/src/utils/presets.ts @@ -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) { + 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 +}