mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
fix
This commit is contained in:
@@ -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'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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -98,7 +98,6 @@ export const designSystemConfigSchema = z
|
||||
.enum(["next", "next-monorepo", "start", "vite"])
|
||||
.default("next")
|
||||
.optional(),
|
||||
new: z.boolean().default(true),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user