diff --git a/apps/v4/app/(create)/components/project-form.tsx b/apps/v4/app/(create)/components/project-form.tsx index e0444c33ab..3ca5c1ce94 100644 --- a/apps/v4/app/(create)/components/project-form.tsx +++ b/apps/v4/app/(create)/components/project-form.tsx @@ -1,7 +1,6 @@ "use client" import * as React from "react" -import { FieldSeparator } from "@/examples/radix/ui/field" import { ComputerTerminal01Icon, Copy01Icon, @@ -12,6 +11,11 @@ import { HugeiconsIcon } from "@hugeicons/react" import { useConfig } from "@/hooks/use-config" import { copyToClipboardWithMeta } from "@/components/copy-button" import { Button } from "@/registry/new-york-v4/ui/button" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/registry/new-york-v4/ui/collapsible" import { Dialog, DialogContent, @@ -33,6 +37,7 @@ import { RadioGroup, RadioGroupItem, } from "@/registry/new-york-v4/ui/radio-group" +import { Switch } from "@/registry/new-york-v4/ui/switch" import { Tabs, TabsContent, @@ -74,41 +79,25 @@ export function ProjectForm() { const commands = React.useMemo(() => { const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000" - const url = `${origin}/init?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&template=${params.template}&rtl=${params.rtl}&new=${params.new}` + const url = `${origin}/init?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&template=${params.template}&rtl=${params.rtl}` const rtlFlag = params.rtl ? " --rtl" : "" const templateFlag = params.template ? ` --template ${params.template}` : "" const isLocalDev = origin.includes("localhost") - if (!params.new) { - return isLocalDev - ? { - pnpm: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, - npm: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, - yarn: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, - bun: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, - } - : { - pnpm: `pnpm dlx shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, - npm: `npx shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, - yarn: `yarn dlx shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, - bun: `bunx --bun shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, - } - } return isLocalDev ? { - pnpm: `pnpm shadcn create${rtlFlag} --preset "${url}"${templateFlag}`, - npm: `pnpm shadcn create${rtlFlag} --preset "${url}"${templateFlag}`, - yarn: `pnpm shadcn create${rtlFlag} --preset "${url}"${templateFlag}`, - bun: `pnpm shadcn create${rtlFlag} --preset "${url}"${templateFlag}`, + pnpm: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, + npm: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, + yarn: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, + bun: `pnpm shadcn init${rtlFlag} --preset "${url}"${templateFlag}`, } : { - pnpm: `pnpm dlx shadcn@latest create${rtlFlag} --preset "${url}"${templateFlag}`, - npm: `npx shadcn@latest create${rtlFlag} --preset "${url}"${templateFlag}`, - yarn: `yarn dlx shadcn@latest create${rtlFlag} --preset "${url}"${templateFlag}`, - bun: `bunx --bun shadcn@latest create${rtlFlag} --preset "${url}"${templateFlag}`, + pnpm: `pnpm dlx shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, + npm: `npx shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, + yarn: `yarn dlx shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, + bun: `bunx --bun shadcn@latest init${rtlFlag} --preset "${url}"${templateFlag}`, } }, [ - params.new, params.base, params.style, params.baseColor, @@ -131,12 +120,6 @@ export function ProjectForm() { } }, [hasCopied]) - React.useEffect(() => { - if (!params.new && params.template === "next-monorepo") { - setParams({ template: "next" }) - } - }, [params.new, params.template, setParams]) - const handleCopy = React.useCallback(() => { const properties: Record = { command, @@ -163,7 +146,7 @@ export function ProjectForm() { icon={ComputerTerminal01Icon} className="hidden xl:flex" /> - Create Project + Copy Command @@ -174,50 +157,9 @@ export function ProjectForm() { - - - Are you creating a new project? - - setParams({ new: value === "new" })} - className="grid grid-cols-2 gap-2" - > - - - - Yes - - I'm creating a new project. - - - - - - - - - No - - I have an existing project. - - - - - - - - {params.new - ? `The cli will create a new project, install dependencies, add CSS variables and utils, configure dark mode and add an example component.` - : `The cli will install dependencies, add CSS variables and utils in your existing project.`} - - - - {params.new - ? "Choose a starter template" - : "What framework is your existing project using?"} + Choose a template - {TEMPLATES.map((template) => { - const isDisabled = - !params.new && template.value === "next-monorepo" - - return ( - - - ( + + + + {template.logo ? ( +
- {template.logo ? ( -
- ) : null} - {template.title} - - - ) - })} + ) : null} + {template.title} + + + ))} See the{" "} @@ -279,52 +209,53 @@ export function ProjectForm() { for more templates and frameworks. - - - - Do you want to enable RTL? - - setParams({ rtl: value === "yes" })} - className="grid grid-cols-2 gap-2" - > - - - - No - - Use default left-to-right layout. - - - - - - - - - Yes - - Enable right-to-left support. - - - - - - - - To learn more about RTL, see the{" "} - + + Options + - RTL setup guide - {" "} - for {selectedTemplate?.title}. - - + + + + + + + + Enable RTL + + + Enable right-to-left support. See the{" "} + + RTL setup guide + {" "} + for {selectedTemplate?.title}. + + + + setParams({ rtl: checked === true }) + } + /> + + + ( RADII.map((r) => r.name) ).withDefault("default"), - new: parseAsBoolean.withDefault(true), template: parseAsStringLiteral([ "next", "next-monorepo", diff --git a/apps/v4/registry/config.ts b/apps/v4/registry/config.ts index edfdda7418..bfe1e20b6e 100644 --- a/apps/v4/registry/config.ts +++ b/apps/v4/registry/config.ts @@ -98,7 +98,6 @@ export const designSystemConfigSchema = z .enum(["next", "next-monorepo", "start", "vite"]) .default("next") .optional(), - new: z.boolean().default(true), }) .refine( (data) => { diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts index 08726092e2..4df5fe1ed6 100644 --- a/packages/shadcn/src/commands/add.ts +++ b/packages/shadcn/src/commands/add.ts @@ -168,6 +168,7 @@ export const add = new Command() silent: options.silent && !hasNewRegistries, isNewProject: false, cssVariables: options.cssVariables, + rtl: false, installStyleIndex: shouldInstallStyleIndex, components: options.components, }) @@ -201,6 +202,7 @@ export const add = new Command() silent: !hasNewRegistries && options.silent, isNewProject: true, cssVariables: options.cssVariables, + rtl: false, installStyleIndex: shouldInstallStyleIndex, components: options.components, }) diff --git a/packages/shadcn/src/commands/create.ts b/packages/shadcn/src/commands/create.ts index 4697c8f34c..85204fafc5 100644 --- a/packages/shadcn/src/commands/create.ts +++ b/packages/shadcn/src/commands/create.ts @@ -1,17 +1,14 @@ import path from "path" -import { getRegistryItems } from "@/src/registry/api" +import { getPreset, getPresets, getRegistryItems } from "@/src/registry/api" import { configWithDefaults } from "@/src/registry/config" import { clearRegistryContext } from "@/src/registry/context" +import { isUrl } from "@/src/registry/utils" import { templates } from "@/src/templates/index" 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 { resolveCreateUrl, resolveInitUrl } from "@/src/utils/presets" import { ensureRegistriesInConfig } from "@/src/utils/registries" import { updateFiles } from "@/src/utils/updaters/update-files" import { Command } from "commander" @@ -42,17 +39,21 @@ export const create = new Command() // If no preset provided, open create URL with template and rtl params. const hasNoPreset = !name && !opts.preset if (hasNoPreset) { - const searchParams: Record = {} + const searchParams: { + template?: string + rtl?: boolean + base?: string + } = {} if (opts.template) { searchParams.template = opts.template } if (opts.rtl) { - searchParams.rtl = "true" + searchParams.rtl = true // Recommend base-ui in RTL. searchParams.base = "base" } - const createUrl = getShadcnCreateUrl( + const createUrl = resolveCreateUrl( Object.keys(searchParams).length > 0 ? searchParams : undefined ) logger.log("Build your own shadcn/ui.") @@ -127,29 +128,78 @@ export const create = new Command() } // Handle preset selection. - const presetResult = await handlePresetOption( - opts.preset ?? true, - opts.rtl, - "create" - ) - - if (!presetResult) { - process.exit(0) - } - - // Determine initUrl based on preset type. let initUrl: string + const presetArg = opts.preset ?? true - if ("_isUrl" in presetResult) { - // User provided a URL directly. - const url = new URL(presetResult.url) + if (presetArg === true) { + // Show interactive preset list. + 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) { + process.exit(0) + } + + if (selectedPreset === "custom") { + const createUrl = resolveCreateUrl({ + command: "create", + ...(opts.rtl && { rtl: true }), + ...(template && { template }), + }) + logger.info( + `\nOpening ${highlighter.info(createUrl)} in your browser...\n` + ) + await open(createUrl) + process.exit(0) + } + + const preset = presets.find((p) => p.name === selectedPreset) + if (!preset) { + process.exit(0) + } + initUrl = resolveInitUrl({ + ...preset, + rtl: opts.rtl || preset.rtl, + }) + } else if (isUrl(presetArg)) { + // Direct URL. + const url = new URL(presetArg) if (opts.rtl) { url.searchParams.set("rtl", "true") } initUrl = url.toString() } else { - // User selected a preset by name. - initUrl = buildInitUrl(presetResult, opts.rtl) + // Preset 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) + } + initUrl = resolveInitUrl({ + ...preset, + rtl: opts.rtl || preset.rtl, + }) } // Fetch the registry:base item to get its config. diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index f472c4939c..7ca16e594b 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -10,6 +10,7 @@ import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder" import { configWithDefaults } from "@/src/registry/config" import { BUILTIN_REGISTRIES } from "@/src/registry/constants" import { clearRegistryContext } from "@/src/registry/context" +import { isUrl } from "@/src/registry/utils" import { rawConfigSchema } from "@/src/schema" import { templates } from "@/src/templates/index" import { addComponents } from "@/src/utils/add-components" @@ -41,9 +42,9 @@ import { handleError } from "@/src/utils/handle-error" import { highlighter } from "@/src/utils/highlighter" import { logger } from "@/src/utils/logger" import { - buildInitUrl, - getShadcnCreateUrl, - handlePresetOption, + DEFAULT_PRESETS, + resolveCreateUrl, + resolveInitUrl, } from "@/src/utils/presets" import { ensureRegistriesInConfig } from "@/src/utils/registries" import { spinner } from "@/src/utils/spinner" @@ -54,45 +55,20 @@ import open from "open" import prompts from "prompts" import { z } from "zod" -const DEFAULT_INIT_PRESET = { - style: "nova", - baseColor: "neutral", - theme: "neutral", - iconLibrary: "lucide", - font: "geist", - menuAccent: "subtle", - menuColor: "default", - radius: "default", -} as const - export const initOptionsSchema = z.object({ cwd: z.string(), name: z.string().optional(), + preset: z.union([z.boolean(), z.string()]).optional(), components: z.array(z.string()).optional(), yes: z.boolean(), defaults: z.boolean(), force: z.boolean(), silent: z.boolean(), - isNewProject: z.boolean(), - cssVariables: z.boolean(), - rtl: z.boolean().optional(), - template: z - .string() - .optional() - .refine( - (val) => { - if (val) { - return val in templates - } - return true - }, - { - message: - "Invalid template. Please use 'next', 'vite', 'start' or 'next-monorepo'.", - } - ), - installStyleIndex: z.boolean(), - // Config from registry:base item to merge into components.json. + isNewProject: z.boolean().default(false), + cssVariables: z.boolean().default(true), + rtl: z.boolean().default(false), + template: z.string().optional(), + installStyleIndex: z.boolean().default(true), registryBaseConfig: rawConfigSchema.deepPartial().optional(), }) @@ -126,25 +102,29 @@ export const init = new Command() let componentsJsonBackupPath: string | undefined try { - if (opts.defaults) { - opts.template = opts.template || "next" - const initUrl = buildInitUrl( - { - ...DEFAULT_INIT_PRESET, - base: "base", - rtl: opts.rtl ?? false, - }, - opts.rtl ?? false - ) + const options = initOptionsSchema.parse({ + ...opts, + cwd: path.resolve(opts.cwd), + }) + const presets = Object.values(DEFAULT_PRESETS) + const presetsByName = new Map( + presets.map((preset) => [preset.name, preset]) + ) + + if (options.defaults) { + options.template = options.template || "next" + const initUrl = resolveInitUrl({ + ...DEFAULT_PRESETS["base-nova"], + rtl: options.rtl, + }) components = [initUrl, ...components] } - if (opts.template && !(opts.template in templates)) { - logger.break() + if (options.template && !(options.template in templates)) { logger.error( `Invalid template: ${highlighter.info( - opts.template - )}. Use ${Object.keys(templates) + options.template + )}. Available templates: ${Object.keys(templates) .map((t) => highlighter.info(t)) .join(", ")}.` ) @@ -152,12 +132,25 @@ export const init = new Command() process.exit(1) } - const cwd = path.resolve(opts.cwd) + if (typeof options.preset === "string" && !isUrl(options.preset)) { + const knownPresetNames = presets.map((preset) => preset.name) + + if (!presetsByName.has(options.preset)) { + logger.error( + `Invalid preset: ${highlighter.info( + options.preset + )}. Available presets: ${knownPresetNames.join(", ")}` + ) + logger.break() + process.exit(1) + } + } + + const cwd = options.cwd if ( fsExtra.existsSync(path.resolve(cwd, "components.json")) && - !opts.force + !options.force ) { - logger.break() logger.error( `A ${highlighter.info( "components.json" @@ -171,36 +164,10 @@ export const init = new Command() process.exit(1) } - if (opts.preset !== undefined) { - const presetResult = await handlePresetOption( - opts.preset === true ? true : opts.preset, - opts.rtl ?? false, - "init" - ) - - if (!presetResult) { - process.exit(0) - } - - let initUrl: string - - if ("_isUrl" in presetResult) { - const url = new URL(presetResult.url) - if (opts.rtl) { - url.searchParams.set("rtl", "true") - } - initUrl = url.toString() - } else { - initUrl = buildInitUrl(presetResult, opts.rtl ?? false) - } - - components = [initUrl, ...components] - } - if ( - opts.preset === undefined && + options.preset === undefined && components.length === 0 && - !opts.defaults + !options.defaults ) { // Determine template for the create URL. const hasPackageJson = fsExtra.existsSync( @@ -208,7 +175,7 @@ export const init = new Command() ) // Prompt for template only for new projects without -t flag. - if (!opts.template && !hasPackageJson) { + if (!options.template && !hasPackageJson) { const { template } = await prompts({ type: "select", name: "template", @@ -223,103 +190,155 @@ export const init = new Command() process.exit(0) } - opts.template = template + options.template = template } - // Build create URL with template param. - const createUrl = getShadcnCreateUrl({ - new: hasPackageJson ? "false" : "true", - ...(opts.rtl && { rtl: "true" }), - ...(opts.template && { template: opts.template }), - }) - - const { preset } = await prompts({ - type: "select", - name: "preset", - message: "Select a preset", - choices: [ - { - title: "Build your own", - description: "Build a custom preset on ui.shadcn.com", - value: "create", - }, - { - title: "Radix UI", - description: "Nova / Lucide / Geist", - value: "radix", - }, - { - title: "Base UI", - description: "Nova / Lucide / Geist", - value: "base", - }, - ], - }) - - if (!preset) { - process.exit(0) - } - - if (preset === "create") { - logger.info( - `\nOpening ${highlighter.info(createUrl)} in your browser...\n` + // Try to infer template for existing projects. + if (!options.template && hasPackageJson) { + const projectInfo = await getProjectInfo(cwd) + const detectedTemplate = getTemplateFromFrameworkName( + projectInfo?.framework.name ) - await open(createUrl) - process.exit(0) + if (detectedTemplate) { + options.template = detectedTemplate + } } - // User chose a default base (radix or base). - const initUrl = buildInitUrl( - { - ...DEFAULT_INIT_PRESET, - base: preset, - rtl: opts.rtl ?? false, - }, - opts.rtl ?? false - ) - components = [initUrl, ...components] + // Show interactive preset list. + options.preset = true } - const options = initOptionsSchema.parse({ - isNewProject: false, + if (options.preset !== undefined) { + const presetArg = options.preset === true ? true : options.preset + + if (presetArg === true) { + 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) { + process.exit(0) + } + + if (selectedPreset === "custom") { + const createUrl = resolveCreateUrl({ + command: "init", + rtl: options.rtl, + ...(options.template && { template: options.template }), + }) + logger.break() + logger.log( + ` Build your custom preset on ${highlighter.info(createUrl)}` + ) + logger.log( + ` Then ${highlighter.info( + "copy and run the command" + )} from ui.shadcn.com.` + ) + logger.break() + + const { proceed } = await prompts({ + type: "confirm", + name: "proceed", + message: "Open in browser?", + initial: true, + }) + + if (proceed) { + await open(createUrl) + } + + process.exit(0) + } + + const preset = presets.find((p) => p.name === selectedPreset) + if (!preset) { + process.exit(0) + } + const initUrl = resolveInitUrl({ + ...preset, + rtl: options.rtl, + }) + components = [initUrl, ...components] + } + + if (typeof presetArg === "string") { + let initUrl: string + + if (isUrl(presetArg)) { + const url = new URL(presetArg) + if (options.rtl) { + url.searchParams.set("rtl", "true") + } + initUrl = url.toString() + } else { + const preset = presetsByName.get(presetArg)! + initUrl = resolveInitUrl({ + ...preset, + rtl: options.rtl || preset.rtl, + }) + } + + components = [initUrl, ...components] + } + } + + const parsedOptions = initOptionsSchema.parse({ components, - ...opts, - cwd: path.resolve(opts.cwd), - installStyleIndex: true, + ...options, + cwd: options.cwd, }) - await loadEnvFiles(options.cwd) + await loadEnvFiles(parsedOptions.cwd) // We need to check if we're initializing with a new style. // This will allow us to determine if we need to install the base style. - // And if we should prompt the user for a base color. if (components.length > 0) { // We don't know the full config at this point. // So we'll use a shadow config to fetch the first item. let shadowConfig = configWithDefaults( createConfig({ resolvedPaths: { - cwd: options.cwd, + cwd: parsedOptions.cwd, }, }) ) // Check if there's a components.json file. // If so, we'll merge with our shadow config. - const componentsJsonPath = path.resolve(options.cwd, "components.json") + const componentsJsonPath = path.resolve( + parsedOptions.cwd, + "components.json" + ) if (fsExtra.existsSync(componentsJsonPath)) { const existingConfig = await fsExtra.readJson(componentsJsonPath) const config = rawConfigSchema.partial().parse(existingConfig) const baseConfig = createConfig({ resolvedPaths: { - cwd: options.cwd, + cwd: parsedOptions.cwd, }, }) shadowConfig = configWithDefaults({ ...config, resolvedPaths: { ...baseConfig.resolvedPaths, - cwd: options.cwd, + cwd: parsedOptions.cwd, }, }) @@ -353,7 +372,10 @@ export const init = new Command() config: shadowConfig, }) - // Set options from registry:base. + if (item?.extends === "none") { + parsedOptions.installStyleIndex = false + } + if (item?.type === "registry:base") { if (item.config) { // Merge config values into shadowConfig. @@ -361,27 +383,19 @@ export const init = new Command() deepmerge(shadowConfig, item.config) ) // Store config to be merged into components.json later. - options.registryBaseConfig = item.config + parsedOptions.registryBaseConfig = item.config } - options.installStyleIndex = - item.extends === "none" ? false : options.installStyleIndex - } - - if (item?.type === "registry:style") { - // If the style extends none, we don't want to install the base style. - options.installStyleIndex = - item.extends === "none" ? false : options.installStyleIndex } } - await runInit(options) + await runInit(parsedOptions) logger.log( `Project initialization completed.\nYou may now add components.` ) // We need when running with custom cwd. - deleteFileBackup(path.resolve(options.cwd, "components.json")) + deleteFileBackup(path.resolve(parsedOptions.cwd, "components.json")) logger.break() } catch (error) { if (componentsJsonBackupPath) { @@ -715,3 +729,19 @@ async function promptForMinimalConfig( aliases: defaultConfig?.aliases, }) } + +function getTemplateFromFrameworkName(frameworkName?: string) { + if (frameworkName === "next-app" || frameworkName === "next-pages") { + return "next" + } + + if (frameworkName === "vite") { + return "vite" + } + + if (frameworkName === "tanstack-start" || frameworkName === "react-router") { + return "start" + } + + return undefined +} diff --git a/packages/shadcn/src/utils/presets.test.ts b/packages/shadcn/src/utils/presets.test.ts index 49841f8dc3..c78644347f 100644 --- a/packages/shadcn/src/utils/presets.test.ts +++ b/packages/shadcn/src/utils/presets.test.ts @@ -1,43 +1,7 @@ -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 { describe, expect, it } 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), - }, -})) +import { resolveCreateUrl, resolveInitUrl } from "./presets" const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "") @@ -57,19 +21,16 @@ const mockPreset = { 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`) +describe("createPresetUrl", () => { + it("should not include rtl by default", () => { + const url = resolveCreateUrl() + const parsed = new URL(url) + expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/create`) + expect(parsed.searchParams.has("rtl")).toBe(false) }) it("should append search params when provided", () => { - const url = getShadcnCreateUrl({ rtl: "true", template: "next" }) + const url = resolveCreateUrl({ rtl: true, template: "next" }) const parsed = new URL(url) expect(parsed.searchParams.get("rtl")).toBe("true") expect(parsed.searchParams.get("template")).toBe("next") @@ -78,7 +39,7 @@ describe("getShadcnCreateUrl", () => { describe("buildInitUrl", () => { it("should build url with all preset fields as query params", () => { - const url = buildInitUrl(mockPreset, false) + const url = resolveInitUrl(mockPreset) const parsed = new URL(url) expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`) expect(parsed.searchParams.get("base")).toBe("radix") @@ -93,154 +54,9 @@ describe("buildInitUrl", () => { 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 url = resolveInitUrl({ ...mockPreset, rtl: true }) 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() - }) - - it("should exit with error when preset name is empty string", async () => { - vi.mocked(getPreset).mockResolvedValue(null) - mockExit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never) - - await handlePresetOption("", false) - - expect(mockExit).toHaveBeenCalledWith(1) - }) - - it("should list available presets in error when not found", async () => { - const { logger } = await import("@/src/utils/logger") - const secondPreset = { ...mockPreset, name: "minimal", title: "Minimal" } - vi.mocked(getPreset).mockResolvedValue(null) - vi.mocked(getPresets).mockResolvedValue([mockPreset, secondPreset]) - mockExit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never) - - await handlePresetOption("nonexistent", false) - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("default, minimal") - ) - }) - - it("should pass rtl to create url when custom is selected", async () => { - vi.mocked(prompts).mockResolvedValue({ selectedPreset: "custom" }) - - await handlePresetOption(true, true) - - expect(open).toHaveBeenCalledWith(expect.stringContaining("rtl=true")) - }) - - it("should select correct preset from multiple options", async () => { - const secondPreset = { - ...mockPreset, - name: "minimal", - title: "Minimal", - baseColor: "zinc", - } - vi.mocked(getPresets).mockResolvedValue([mockPreset, secondPreset]) - vi.mocked(prompts).mockResolvedValue({ selectedPreset: "minimal" }) - - const result = await handlePresetOption(true, false) - - expect(result).toEqual(secondPreset) - }) - - it("should propagate error when getPresets fails", async () => { - vi.mocked(getPresets).mockRejectedValue(new Error("Network error")) - - await expect(handlePresetOption(true, false)).rejects.toThrow( - "Network error" - ) - }) - - it("should propagate error when getPreset fails", async () => { - vi.mocked(getPreset).mockRejectedValue(new Error("Network error")) - - await expect(handlePresetOption("default", false)).rejects.toThrow( - "Network error" - ) - }) -}) diff --git a/packages/shadcn/src/utils/presets.ts b/packages/shadcn/src/utils/presets.ts index 75fefa5d34..66303627e4 100644 --- a/packages/shadcn/src/utils/presets.ts +++ b/packages/shadcn/src/utils/presets.ts @@ -1,43 +1,68 @@ -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" +import { type Preset } from "@/src/schema" const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "") -export function getShadcnCreateUrl(searchParams?: Record) { +export const DEFAULT_PRESETS = { + "radix-nova": { + name: "radix-nova", + title: "Radix", + description: "Nova / Lucide / Geist", + base: "radix", + style: "nova", + baseColor: "neutral", + theme: "neutral", + iconLibrary: "lucide", + font: "geist", + menuAccent: "subtle", + menuColor: "default", + radius: "default", + rtl: false, + }, + "base-nova": { + name: "base-nova", + title: "Base", + description: "Nova / Lucide / Geist", + base: "base", + style: "nova", + baseColor: "neutral", + theme: "neutral", + iconLibrary: "lucide", + font: "geist", + menuAccent: "subtle", + menuColor: "default", + radius: "default", + rtl: false, + }, +} satisfies Record + +export function resolveCreateUrl( + searchParams?: Partial<{ + command: "create" | "init" + template: string + rtl: boolean + base: string + }> +) { const url = new URL(`${SHADCN_URL}/create`) - if (searchParams) { - for (const [key, value] of Object.entries(searchParams)) { - url.searchParams.set(key, value) + const { rtl, ...params } = searchParams ?? {} + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)) } } + + // Do not set rtl if it's false. + if (rtl) { + url.searchParams.set("rtl", "true") + } + return url.toString() } -export function getShadcnInitUrl() { - return `${SHADCN_URL}/init` -} - -export function buildInitUrl( - preset: Pick< - Preset, - | "base" - | "style" - | "baseColor" - | "theme" - | "iconLibrary" - | "font" - | "rtl" - | "menuAccent" - | "menuColor" - | "radius" - >, - rtl: boolean +export function resolveInitUrl( + preset: Omit ) { const params = new URLSearchParams({ base: preset.base, @@ -46,80 +71,11 @@ export function buildInitUrl( theme: preset.theme, iconLibrary: preset.iconLibrary, font: preset.font, - rtl: String(rtl || preset.rtl), + rtl: String(preset.rtl ?? false), menuAccent: preset.menuAccent, menuColor: preset.menuColor, radius: preset.radius, }) - return `${getShadcnInitUrl()}?${params.toString()}` -} - -export async function handlePresetOption( - presetArg: string | boolean, - rtl: boolean, - command: "create" | "init" = "create" -) { - // 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({ - command, - ...(rtl && { rtl: "true" }), - }) - 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 + return `${SHADCN_URL}/init?${params.toString()}` } diff --git a/templates/next-app/app/page.tsx b/templates/next-app/app/page.tsx index 7fbaddb74b..c9a221328e 100644 --- a/templates/next-app/app/page.tsx +++ b/templates/next-app/app/page.tsx @@ -1,6 +1,6 @@ export default function Page() { return ( -
+

Hello World

) diff --git a/templates/next-monorepo/apps/web/app/page.tsx b/templates/next-monorepo/apps/web/app/page.tsx index 7fbaddb74b..c9a221328e 100644 --- a/templates/next-monorepo/apps/web/app/page.tsx +++ b/templates/next-monorepo/apps/web/app/page.tsx @@ -1,6 +1,6 @@ export default function Page() { return ( -
+

Hello World

) diff --git a/templates/start-app/src/routes/index.tsx b/templates/start-app/src/routes/index.tsx index 0a9b0defd1..731d8c38dd 100644 --- a/templates/start-app/src/routes/index.tsx +++ b/templates/start-app/src/routes/index.tsx @@ -4,7 +4,7 @@ export const Route = createFileRoute("/")({ component: App }) function App() { return ( -
+

Hello World

) diff --git a/templates/vite-app/src/App.tsx b/templates/vite-app/src/App.tsx index 3211a40abf..d69bd75b8b 100644 --- a/templates/vite-app/src/App.tsx +++ b/templates/vite-app/src/App.tsx @@ -1,6 +1,6 @@ export function App() { return ( -
+

Hello World

)