This commit is contained in:
shadcn
2026-02-14 23:16:30 +04:00
parent 1ecc8066db
commit e9af9efaf3
13 changed files with 414 additions and 632 deletions

View File

@@ -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<string, string> = {
command,
@@ -163,7 +146,7 @@ export function ProjectForm() {
icon={ComputerTerminal01Icon}
className="hidden xl:flex"
/>
Create Project
Copy Command
</Button>
</DialogTrigger>
<DialogContent className="dialog-ring min-w-0 overflow-hidden rounded-xl sm:max-w-lg">
@@ -174,50 +157,9 @@ export function ProjectForm() {
</DialogDescription>
</DialogHeader>
<FieldGroup className="dark:**:data-[slot=field-label]:has-data-[state=checked]:bg-primary/10 dark:**:data-[slot=field-label]:has-data-[state=checked]:border-primary **:data-[slot=field-description]:text-balance **:data-[slot=field-label]:rounded-lg! **:data-[slot=field-label]:has-data-[state=checked]:border-blue-600 **:data-[slot=field-label]:has-data-[state=checked]:bg-blue-50/50 **:data-[slot=radio-group-item]:sr-only **:data-[slot=radio-group-item]:absolute">
<Field>
<FieldLabel className="text-base">
Are you creating a new project?
</FieldLabel>
<RadioGroup
value={params.new ? "new" : "existing"}
onValueChange={(value) => setParams({ new: value === "new" })}
className="grid grid-cols-2 gap-2"
>
<FieldLabel htmlFor="project-new">
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>Yes</FieldTitle>
<FieldDescription>
I&apos;m creating a new project.
</FieldDescription>
</FieldContent>
<RadioGroupItem value="new" id="project-new" />
</Field>
</FieldLabel>
<FieldLabel htmlFor="project-existing">
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>No</FieldTitle>
<FieldDescription>
I have an existing project.
</FieldDescription>
</FieldContent>
<RadioGroupItem value="existing" id="project-existing" />
</Field>
</FieldLabel>
</RadioGroup>
<FieldDescription>
{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.`}
</FieldDescription>
</Field>
<FieldSeparator />
<Field>
<FieldLabel htmlFor="template" className="text-base">
{params.new
? "Choose a starter template"
: "What framework is your existing project using?"}
Choose a template
</FieldLabel>
<RadioGroup
id="template"
@@ -233,38 +175,26 @@ export function ProjectForm() {
}}
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((template) => {
const isDisabled =
!params.new && template.value === "next-monorepo"
return (
<FieldLabel
key={template.value}
htmlFor={template.value}
className={
isDisabled ? "cursor-not-allowed opacity-50" : undefined
}
>
<Field className="flex min-w-0 flex-col items-center justify-center gap-2 p-4! text-center *:w-auto!">
<RadioGroupItem
value={template.value}
id={template.value}
className="sr-only"
disabled={isDisabled}
{TEMPLATES.map((template) => (
<FieldLabel key={template.value} htmlFor={template.value}>
<Field className="flex min-w-0 flex-col items-center justify-center gap-2 p-4! text-center *:w-auto!">
<RadioGroupItem
value={template.value}
id={template.value}
className="sr-only"
/>
{template.logo ? (
<div
className="text-foreground *:[svg]:text-foreground! size-6 [&_svg]:size-6"
dangerouslySetInnerHTML={{
__html: template.logo,
}}
/>
{template.logo ? (
<div
className="text-foreground *:[svg]:text-foreground! size-6 [&_svg]:size-6"
dangerouslySetInnerHTML={{
__html: template.logo,
}}
/>
) : null}
<FieldTitle>{template.title}</FieldTitle>
</Field>
</FieldLabel>
)
})}
) : null}
<FieldTitle>{template.title}</FieldTitle>
</Field>
</FieldLabel>
))}
</RadioGroup>
<FieldDescription>
See the{" "}
@@ -279,52 +209,53 @@ export function ProjectForm() {
for more templates and frameworks.
</FieldDescription>
</Field>
<FieldSeparator />
<Field>
<FieldLabel className="text-base">
Do you want to enable RTL?
</FieldLabel>
<RadioGroup
value={params.rtl ? "yes" : "no"}
onValueChange={(value) => setParams({ rtl: value === "yes" })}
className="grid grid-cols-2 gap-2"
>
<FieldLabel htmlFor="rtl-no">
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>No</FieldTitle>
<FieldDescription>
Use default left-to-right layout.
</FieldDescription>
</FieldContent>
<RadioGroupItem value="no" id="rtl-no" />
</Field>
</FieldLabel>
<FieldLabel htmlFor="rtl-yes">
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>Yes</FieldTitle>
<FieldDescription>
Enable right-to-left support.
</FieldDescription>
</FieldContent>
<RadioGroupItem value="yes" id="rtl-yes" />
</Field>
</FieldLabel>
</RadioGroup>
<FieldDescription className="text-balance">
To learn more about RTL, see the{" "}
<a
href={`/docs/rtl/${params.template === "next-monorepo" ? "next" : params.template}`}
className="text-foreground underline"
target="_blank"
rel="noopener noreferrer"
<Collapsible className="rounded-lg border">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2.5 text-sm font-medium transition-colors">
Options
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-transform [[data-state=open]>&]:rotate-180"
>
RTL setup guide
</a>{" "}
for {selectedTemplate?.title}.
</FieldDescription>
</Field>
<path d="m6 9 6 6 6-6" />
</svg>
</CollapsibleTrigger>
<CollapsibleContent className="border-t px-3 py-3">
<Field orientation="horizontal" className="items-center">
<FieldContent className="gap-0.5">
<FieldLabel htmlFor="rtl" className="text-sm">
Enable RTL
</FieldLabel>
<FieldDescription className="text-balance">
Enable right-to-left support. See the{" "}
<a
href={`/docs/rtl/${params.template === "next-monorepo" ? "next" : params.template}`}
className="text-foreground underline"
target="_blank"
rel="noopener noreferrer"
>
RTL setup guide
</a>{" "}
for {selectedTemplate?.title}.
</FieldDescription>
</FieldContent>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</CollapsibleContent>
</Collapsible>
</FieldGroup>
<DialogFooter className="bg-muted/30 -mx-6 mt-2 -mb-6 flex min-w-0 flex-col gap-3 border-t p-6 sm:flex-col">
<Tabs

View File

@@ -20,7 +20,6 @@ export async function GET(request: NextRequest) {
radius: searchParams.get("radius"),
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
new: searchParams.get("new") !== "false",
})
if (!result.success) {

View File

@@ -61,7 +61,6 @@ const designSystemSearchParams = {
radius: parseAsStringLiteral<RadiusValue>(
RADII.map((r) => r.name)
).withDefault("default"),
new: parseAsBoolean.withDefault(true),
template: parseAsStringLiteral([
"next",
"next-monorepo",

View File

@@ -98,7 +98,6 @@ export const designSystemConfigSchema = z
.enum(["next", "next-monorepo", "start", "vite"])
.default("next")
.optional(),
new: z.boolean().default(true),
})
.refine(
(data) => {

View File

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

View File

@@ -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<string, string> = {}
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.

View File

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

View File

@@ -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"
)
})
})

View File

@@ -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<string, string>) {
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<string, Preset>
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<Preset, "name" | "title" | "description">
) {
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()}`
}

View File

@@ -1,6 +1,6 @@
export default function Page() {
return (
<div className="flex min-h-svh items-center justify-center p-4">
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6">
<p>Hello World</p>
</div>
)

View File

@@ -1,6 +1,6 @@
export default function Page() {
return (
<div className="flex min-h-svh items-center justify-center p-4">
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6">
<p>Hello World</p>
</div>
)

View File

@@ -4,7 +4,7 @@ export const Route = createFileRoute("/")({ component: App })
function App() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6">
<p>Hello World</p>
</div>
)

View File

@@ -1,6 +1,6 @@
export function App() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6">
<p>Hello World</p>
</div>
)