feat: refactor to templates

This commit is contained in:
shadcn
2026-02-14 10:24:34 +04:00
parent bd5028e331
commit 3e4c608aca
12 changed files with 581 additions and 510 deletions

View File

@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
import { FieldSeparator } from "@/examples/radix/ui/field"
import {
ComputerTerminal01Icon,
Copy01Icon,
@@ -39,7 +40,6 @@ import {
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { FieldSeparator } from "@/examples/radix/ui/field"
const TEMPLATES = [
{
@@ -82,31 +82,31 @@ export function ProjectForm() {
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 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}`,
}
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 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 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 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}`,
}
}, [
params.new,
params.base,
@@ -168,17 +168,16 @@ export function ProjectForm() {
</DialogTrigger>
<DialogContent className="dialog-ring min-w-0 overflow-hidden rounded-xl sm:max-w-lg">
<DialogHeader>
<DialogTitle>
Create Project
</DialogTitle>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription className="text-balance">
Configure your project to use shadcn/ui.
</DialogDescription>
</DialogHeader>
<FieldGroup className="**:data-[slot=field-label]:has-data-[state=checked]:border-blue-600 **:data-[slot=field-label]:has-data-[state=checked]:bg-blue-50/50 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=radio-group-item]:sr-only **:data-[slot=radio-group-item]:absolute **:data-[slot=field-label]:rounded-lg! **:data-[slot=field-description]:text-balance">
<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>
<FieldLabel className="text-base">
Are you creating a new project?
</FieldLabel>
<RadioGroup
value={params.new ? "new" : "existing"}
onValueChange={(value) => setParams({ new: value === "new" })}
@@ -188,7 +187,9 @@ export function ProjectForm() {
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>Yes</FieldTitle>
<FieldDescription>I&apos;m creating a new project.</FieldDescription>
<FieldDescription>
I&apos;m creating a new project.
</FieldDescription>
</FieldContent>
<RadioGroupItem value="new" id="project-new" />
</Field>
@@ -213,7 +214,7 @@ export function ProjectForm() {
</Field>
<FieldSeparator />
<Field>
<FieldLabel htmlFor="template" className="text-base ">
<FieldLabel htmlFor="template" className="text-base">
{params.new
? "Choose a starter template"
: "What framework is your existing project using?"}
@@ -233,13 +234,16 @@ export function ProjectForm() {
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((template) => {
const isDisabled = !params.new && template.value === "next-monorepo"
const isDisabled =
!params.new && template.value === "next-monorepo"
return (
<FieldLabel
key={template.value}
htmlFor={template.value}
className={isDisabled ? "cursor-not-allowed opacity-50" : undefined}
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
@@ -263,20 +267,29 @@ export function ProjectForm() {
})}
</RadioGroup>
<FieldDescription>
See the <a href="/docs/installation" className="text-foreground underline" target="_blank" rel="noopener noreferrer">installation guides</a> for more templates and frameworks.
See the{" "}
<a
href="/docs/installation"
className="text-foreground underline"
target="_blank"
rel="noopener noreferrer"
>
installation guides
</a>{" "}
for more templates and frameworks.
</FieldDescription>
</Field>
<FieldSeparator />
<Field>
<FieldLabel className="text-base">Do you want to enable RTL?</FieldLabel>
<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"
>
<FieldLabel htmlFor="rtl-no">
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>No</FieldTitle>
@@ -287,9 +300,7 @@ export function ProjectForm() {
<RadioGroupItem value="no" id="rtl-no" />
</Field>
</FieldLabel>
<FieldLabel
htmlFor="rtl-yes"
>
<FieldLabel htmlFor="rtl-yes">
<Field orientation="horizontal" className="p-3!">
<FieldContent className="gap-1">
<FieldTitle>Yes</FieldTitle>
@@ -302,19 +313,20 @@ export function ProjectForm() {
</FieldLabel>
</RadioGroup>
<FieldDescription className="text-balance">
To learn more about RTL, see the <a
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"
>
RTL setup guide
</a> for {selectedTemplate?.title}.
</a>{" "}
for {selectedTemplate?.title}.
</FieldDescription>
</Field>
</FieldGroup>
<DialogFooter className="bg-muted/30 -mx-6 mt-2 -mb-6 flex flex-col gap-3 border-t p-6 sm:flex-col min-w-0">
<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
value={packageManager}
onValueChange={(value) => {

View File

@@ -19,10 +19,10 @@ import { Customizer } from "@/app/(create)/components/customizer"
import { ItemExplorer } from "@/app/(create)/components/item-explorer"
import { ItemPicker } from "@/app/(create)/components/item-picker"
import { Preview } from "@/app/(create)/components/preview"
import { ProjectForm } from "@/app/(create)/components/project-form"
import { RandomButton } from "@/app/(create)/components/random-button"
import { ResetButton } from "@/app/(create)/components/reset-button"
import { ShareButton } from "@/app/(create)/components/share-button"
import { ProjectForm } from "@/app/(create)/components/project-form"
import { V0Button } from "@/app/(create)/components/v0-button"
import { WelcomeDialog } from "@/app/(create)/components/welcome-dialog"
import { getItemsForBase } from "@/app/(create)/lib/api"

View File

@@ -12,24 +12,15 @@ import {
handlePresetOption,
} from "@/src/utils/presets"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { templates } from "@/src/utils/templates/index"
import { updateFiles } from "@/src/utils/updaters/update-files"
import { Command } from "commander"
import dedent from "dedent"
import open from "open"
import prompts from "prompts"
import validateProjectName from "validate-npm-package-name"
import { initOptionsSchema, runInit } from "./init"
const CREATE_TEMPLATES = {
next: "Next.js",
"next-monorepo": "Next.js (Monorepo)",
vite: "Vite",
start: "TanStack Start",
} as const
type Template = keyof typeof CREATE_TEMPLATES
export const create = new Command()
.name("create")
.description("create a new project with shadcn/ui")
@@ -131,8 +122,8 @@ export const create = new Command()
message: `Which ${highlighter.info(
"template"
)} would you like to use?`,
choices: Object.entries(CREATE_TEMPLATES).map(([key, value]) => ({
title: value,
choices: Object.entries(templates).map(([key, t]) => ({
title: t.title,
value: key,
})),
})
@@ -224,7 +215,8 @@ export const create = new Command()
overwrite: true,
})
const templateFiles = getTemplateFiles(template as Template)
const templateFiles =
templates[template as keyof typeof templates]?.files ?? []
if (templateFiles.length > 0) {
await updateFiles(templateFiles, config, {
overwrite: true,
@@ -246,60 +238,3 @@ export const create = new Command()
clearRegistryContext()
}
})
function getTemplateFiles(template: Template) {
switch (template) {
case "vite":
return [
{
type: "registry:file" as const,
path: "src/App.tsx",
target: "src/App.tsx",
content: dedent`import { ComponentExample } from "@/components/component-example";
export function App() {
return <ComponentExample />;
}
export default App;
`,
},
]
case "next":
case "next-monorepo":
return [
{
type: "registry:page" as const,
path: "app/page.tsx",
target: "app/page.tsx",
content: dedent`import { ComponentExample } from "@/components/component-example";
export default function Page() {
return <ComponentExample />;
}
`,
},
]
case "start":
return [
{
type: "registry:file" as const,
path: "src/routes/index.tsx",
target: "src/routes/index.tsx",
content: dedent`import { createFileRoute } from "@tanstack/react-router";
import { ComponentExample } from "@/components/component-example";
export const Route = createFileRoute("/")({ component: App });
function App() {
return (
<ComponentExample />
);
}
`,
},
]
default:
return []
}
}

View File

@@ -12,7 +12,7 @@ import { BASE_COLORS, BUILTIN_REGISTRIES } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { rawConfigSchema } from "@/src/schema"
import { addComponents } from "@/src/utils/add-components"
import { TEMPLATES, createProject } from "@/src/utils/create-project"
import { createProject } from "@/src/utils/create-project"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import {
@@ -43,15 +43,16 @@ import { logger } from "@/src/utils/logger"
import {
buildInitUrl,
getShadcnCreateUrl,
getShadcnInitUrl,
handlePresetOption,
} from "@/src/utils/presets"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { spinner } from "@/src/utils/spinner"
import { templates } from "@/src/utils/templates/index"
import { updateTailwindContent } from "@/src/utils/updaters/update-tailwind-content"
import { Command } from "commander"
import deepmerge from "deepmerge"
import fsExtra from "fs-extra"
import { bold, gray, green } from "kleur/colors"
import open from "open"
import prompts from "prompts"
import { z } from "zod"
@@ -86,7 +87,7 @@ export const initOptionsSchema = z.object({
.refine(
(val) => {
if (val) {
return TEMPLATES[val as keyof typeof TEMPLATES]
return val in templates
}
return true
},
@@ -162,12 +163,12 @@ export const init = new Command()
}
// Validate template early.
if (opts.template && !TEMPLATES[opts.template as keyof typeof TEMPLATES]) {
if (opts.template && !(opts.template in templates)) {
logger.break()
logger.error(
`Invalid template: ${highlighter.info(
opts.template
)}. Use ${Object.keys(TEMPLATES)
)}. Use ${Object.keys(templates)
.map((t) => highlighter.info(t))
.join(", ")}.`
)
@@ -242,31 +243,9 @@ export const init = new Command()
path.resolve(cwd, "package.json")
)
if (!hasPackageJson) {
// New project: prompt for template (skip if already set via -t flag).
const { template } = await prompts({
type: opts.template ? null : "select",
name: "template",
message: "Which template would you like to use?",
choices: [
{ title: "Next.js", value: "next" },
{ title: "Next.js (Monorepo)", value: "next-monorepo" },
{ title: "Vite", value: "vite" },
{ title: "TanStack Start", value: "start" },
],
})
if (template) {
opts.template = template
}
if (!opts.template) {
process.exit(0)
}
}
// For existing projects, detect framework for the create URL.
if (hasPackageJson && !opts.template) {
// Detect framework for existing projects.
let detectedTemplate: string | undefined
if (hasPackageJson) {
const frameworkTemplateMap: Record<string, string> = {
"next-app": "next",
"next-pages": "next",
@@ -275,7 +254,39 @@ export const init = new Command()
}
const projectInfo = await getProjectInfo(cwd)
if (projectInfo) {
opts.template = frameworkTemplateMap[projectInfo.framework.name]
detectedTemplate = frameworkTemplateMap[projectInfo.framework.name]
}
}
// Use detected framework or prompt for template.
const templateChoices = Object.entries(templates).map(([value, t]) => ({
title: t.title,
value,
}))
if (!opts.template) {
if (detectedTemplate) {
opts.template = detectedTemplate
const title =
templates[detectedTemplate as keyof typeof templates]?.title ??
detectedTemplate
logger.log(
`${green("✔")} ${bold("Select a template")} ${gray(
""
)} ${title} ${gray("(detected)")}`
)
} else {
const { template } = await prompts({
type: "select",
name: "template",
message: "Select a template",
choices: templateChoices,
})
if (!template) {
process.exit(0)
}
opts.template = template
}
}
@@ -289,7 +300,7 @@ export const init = new Command()
const { preset } = await prompts({
type: "select",
name: "preset",
message: "Which preset would you like to use?",
message: "Select a preset",
choices: [
{
title: "Build your own",

View File

@@ -13,7 +13,7 @@ import {
type MockInstance,
} from "vitest"
import { TEMPLATES, createProject } from "./create-project"
import { createProject } from "./create-project"
// Mock dependencies
vi.mock("fs-extra")
@@ -116,7 +116,7 @@ describe("createProject", () => {
expect(result).toEqual({
projectPath: "/test/my-app",
projectName: "my-app",
template: TEMPLATES.next,
template: "next",
})
expect(execa).toHaveBeenCalledWith(
@@ -141,7 +141,7 @@ describe("createProject", () => {
expect(result).toEqual({
projectPath: "/test/my-monorepo",
projectName: "my-monorepo",
template: TEMPLATES["next-monorepo"],
template: "next-monorepo",
})
})
@@ -158,7 +158,7 @@ describe("createProject", () => {
components: ["/chat/b/some-component"],
})
expect(result.template).toBe(TEMPLATES.next)
expect(result.template).toBe("next")
})
it("should throw error if project path already exists", async () => {

View File

@@ -1,4 +1,3 @@
import os from "os"
import path from "path"
import { initOptionsSchema } from "@/src/commands/init"
import { fetchRegistry } from "@/src/registry/fetcher"
@@ -6,22 +5,12 @@ import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { templates } from "@/src/utils/templates/index"
import { execa } from "execa"
import fs from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
const GITHUB_TEMPLATE_URL =
"https://codeload.github.com/shadcn-ui/ui/tar.gz/main"
export const TEMPLATES = {
next: "next",
"next-monorepo": "next-monorepo",
vite: "vite",
start: "start",
} as const
export async function createProject(
options: Pick<
z.infer<typeof initOptionsSchema>,
@@ -33,17 +22,12 @@ export async function createProject(
...options,
}
let template: keyof typeof TEMPLATES =
options.template && TEMPLATES[options.template as keyof typeof TEMPLATES]
? (options.template as keyof typeof TEMPLATES)
let template: keyof typeof templates =
options.template && options.template in templates
? (options.template as keyof typeof templates)
: "next"
let projectName: string =
options.name ??
(template === TEMPLATES.next ||
template === TEMPLATES.vite ||
template === TEMPLATES.start
? "my-app"
: "my-monorepo")
options.name ?? templates[template].defaultProjectName
let nextVersion = "latest"
const isRemoteComponent =
@@ -63,7 +47,7 @@ export async function createProject(
nextVersion = meta.nextVersion
// Force template to next for remote components.
template = TEMPLATES.next
template = "next"
} catch (error) {
logger.break()
handleError(error)
@@ -78,12 +62,10 @@ export async function createProject(
message: `The path ${highlighter.info(
options.cwd
)} does not contain a package.json file.\n Would you like to start a new project?`,
choices: [
{ title: "Next.js", value: "next" },
{ title: "Next.js (Monorepo)", value: "next-monorepo" },
{ title: "Vite", value: "vite" },
{ title: "TanStack Start", value: "start" },
],
choices: Object.entries(templates).map(([key, t]) => ({
title: t.title,
value: key,
})),
initial: 0,
},
{
@@ -134,32 +116,13 @@ export async function createProject(
process.exit(1)
}
if (template === TEMPLATES.next) {
await createNextProject(projectPath, {
version: nextVersion,
cwd: options.cwd,
packageManager,
srcDir: !!options.srcDir,
})
}
if (template === TEMPLATES["next-monorepo"]) {
await createMonorepoProject(projectPath, {
packageManager,
})
}
if (template === TEMPLATES.vite) {
await createViteProject(projectPath, {
packageManager,
})
}
if (template === TEMPLATES.start) {
await createStartProject(projectPath, {
packageManager,
})
}
await templates[template].init({
projectPath,
packageManager,
cwd: options.cwd,
srcDir: !!options.srcDir,
version: nextVersion,
})
return {
projectPath,
@@ -167,309 +130,3 @@ export async function createProject(
template,
}
}
async function createNextProject(
projectPath: string,
options: {
version: string
cwd: string
packageManager: string
srcDir: boolean
}
) {
const createSpinner = spinner(
`Creating a new Next.js project. This may take a few minutes.`
).start()
// Note: pnpm fails here. Fallback to npx with --use-PACKAGE-MANAGER.
const args = [
"--tailwind",
"--eslint",
"--typescript",
"--app",
options.srcDir ? "--src-dir" : "--no-src-dir",
"--no-import-alias",
`--use-${options.packageManager}`,
]
if (
options.version.startsWith("15") ||
options.version.startsWith("latest") ||
options.version.startsWith("canary")
) {
args.push("--turbopack")
}
if (
options.version.startsWith("latest") ||
options.version.startsWith("canary")
) {
args.push("--no-react-compiler")
}
try {
await execa(
"npx",
[`create-next-app@${options.version}`, projectPath, "--silent", ...args],
{
cwd: options.cwd,
}
)
} catch (error) {
logger.break()
logger.error(
`Something went wrong creating a new Next.js project. Please try again.`
)
process.exit(1)
}
createSpinner?.succeed("Creating a new Next.js project.")
}
async function createMonorepoProject(
projectPath: string,
options: {
packageManager: string
}
) {
const createSpinner = spinner(
`Creating a new Next.js monorepo. This may take a few minutes.`
).start()
try {
const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
if (localTemplateDir) {
// Use local template directory for development.
const localTemplatePath = path.resolve(localTemplateDir, "monorepo-next")
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Get the template from GitHub.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await fs.ensureDir(templatePath)
const response = await fetch(GITHUB_TEMPLATE_URL)
if (!response.ok) {
throw new Error(`Failed to download template: ${response.statusText}`)
}
// Write the tar file.
const tarPath = path.resolve(templatePath, "template.tar.gz")
await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
await execa("tar", [
"-xzf",
tarPath,
"-C",
templatePath,
"--strip-components=2",
"ui-main/templates/monorepo-next",
])
const extractedPath = path.resolve(templatePath, "monorepo-next")
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Run install. Disable frozen lockfile since the template's lockfile may not match.
await execa(options.packageManager, ["install"], {
cwd: projectPath,
env: {
...process.env,
CI: "",
},
})
// Write project name to the package.json.
const packageJsonPath = path.join(projectPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
packageJson.name = projectPath.split("/").pop()
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
}
// Try git init.
const cwd = process.cwd()
await execa("git", ["--version"], { cwd: projectPath })
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "Initial commit"], {
cwd: projectPath,
})
createSpinner?.succeed("Creating a new Next.js monorepo.")
} catch (error) {
createSpinner?.fail("Something went wrong creating a new Next.js monorepo.")
handleError(error)
}
}
async function createViteProject(
projectPath: string,
options: {
packageManager: string
}
) {
const createSpinner = spinner(
`Creating a new Vite project. This may take a few minutes.`
).start()
try {
const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
if (localTemplateDir) {
// Use local template directory for development.
const localTemplatePath = path.resolve(localTemplateDir, "vite-app")
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Get the template from GitHub.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await fs.ensureDir(templatePath)
const response = await fetch(GITHUB_TEMPLATE_URL)
if (!response.ok) {
throw new Error(`Failed to download template: ${response.statusText}`)
}
// Write the tar file.
const tarPath = path.resolve(templatePath, "template.tar.gz")
await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
await execa("tar", [
"-xzf",
tarPath,
"-C",
templatePath,
"--strip-components=2",
"ui-main/templates/vite-app",
])
const extractedPath = path.resolve(templatePath, "vite-app")
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Remove pnpm-lock.yaml if using a different package manager.
if (options.packageManager !== "pnpm") {
const lockFilePath = path.join(projectPath, "pnpm-lock.yaml")
if (fs.existsSync(lockFilePath)) {
await fs.remove(lockFilePath)
}
}
// Run install.
await execa(options.packageManager, ["install"], {
cwd: projectPath,
})
// Write project name to the package.json.
const packageJsonPath = path.join(projectPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
packageJson.name = projectPath.split("/").pop()
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
}
// Try git init.
await execa("git", ["--version"], { cwd: projectPath })
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "Initial commit"], {
cwd: projectPath,
})
createSpinner?.succeed("Creating a new Vite project.")
} catch (error) {
createSpinner?.fail("Something went wrong creating a new Vite project.")
handleError(error)
}
}
async function createStartProject(
projectPath: string,
options: {
packageManager: string
}
) {
const createSpinner = spinner(
`Creating a new TanStack Start project. This may take a few minutes.`
).start()
try {
const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
if (localTemplateDir) {
// Use local template directory for development.
const localTemplatePath = path.resolve(localTemplateDir, "start-app")
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Get the template from GitHub.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await fs.ensureDir(templatePath)
const response = await fetch(GITHUB_TEMPLATE_URL)
if (!response.ok) {
throw new Error(`Failed to download template: ${response.statusText}`)
}
// Write the tar file.
const tarPath = path.resolve(templatePath, "template.tar.gz")
await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
await execa("tar", [
"-xzf",
tarPath,
"-C",
templatePath,
"--strip-components=2",
"ui-main/templates/start-app",
])
const extractedPath = path.resolve(templatePath, "start-app")
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Remove pnpm-lock.yaml if using a different package manager.
if (options.packageManager !== "pnpm") {
const lockFilePath = path.join(projectPath, "pnpm-lock.yaml")
if (fs.existsSync(lockFilePath)) {
await fs.remove(lockFilePath)
}
}
// Run install.
await execa(options.packageManager, ["install"], {
cwd: projectPath,
})
// Write project name to the package.json.
const packageJsonPath = path.join(projectPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
packageJson.name = projectPath.split("/").pop()
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
}
// Try git init.
await execa("git", ["--version"], { cwd: projectPath })
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "Initial commit"], {
cwd: projectPath,
})
createSpinner?.succeed("Creating a new TanStack Start project.")
} catch (error) {
createSpinner?.fail(
"Something went wrong creating a new TanStack Start project."
)
handleError(error)
}
}

View File

@@ -0,0 +1,23 @@
import type { RegistryItem } from "@/src/registry/schema"
export interface TemplateOptions {
projectPath: string
packageManager: string
cwd: string
srcDir: boolean
version: string
}
export function createTemplate(config: {
name: string
title: string
defaultProjectName: string
init: (options: TemplateOptions) => Promise<void>
create: (options: TemplateOptions) => Promise<void>
files?: RegistryItem["files"]
}) {
return config
}
export const GITHUB_TEMPLATE_URL =
"https://codeload.github.com/shadcn-ui/ui/tar.gz/main"

View File

@@ -0,0 +1,14 @@
import { next } from "./next"
import { nextMonorepo } from "./next-monorepo"
import { start } from "./start"
import { vite } from "./vite"
export { createTemplate, GITHUB_TEMPLATE_URL } from "./create-template"
export type { TemplateOptions } from "./create-template"
export const templates = {
next,
"next-monorepo": nextMonorepo,
vite,
start,
} as const

View File

@@ -0,0 +1,113 @@
import os from "os"
import path from "path"
import { handleError } from "@/src/utils/handle-error"
import { spinner } from "@/src/utils/spinner"
import dedent from "dedent"
import { execa } from "execa"
import fs from "fs-extra"
import { GITHUB_TEMPLATE_URL, createTemplate } from "./create-template"
export const nextMonorepo = createTemplate({
name: "next-monorepo",
title: "Next.js (Monorepo)",
defaultProjectName: "my-monorepo",
init: async ({ projectPath, packageManager }) => {
const createSpinner = spinner(
`Creating a new Next.js monorepo. This may take a few minutes.`
).start()
try {
const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
if (localTemplateDir) {
// Use local template directory for development.
const localTemplatePath = path.resolve(
localTemplateDir,
"monorepo-next"
)
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Get the template from GitHub.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await fs.ensureDir(templatePath)
const response = await fetch(GITHUB_TEMPLATE_URL)
if (!response.ok) {
throw new Error(`Failed to download template: ${response.statusText}`)
}
// Write the tar file.
const tarPath = path.resolve(templatePath, "template.tar.gz")
await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
await execa("tar", [
"-xzf",
tarPath,
"-C",
templatePath,
"--strip-components=2",
"ui-main/templates/monorepo-next",
])
const extractedPath = path.resolve(templatePath, "monorepo-next")
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Run install. Disable frozen lockfile since the template's lockfile may not match.
await execa(packageManager, ["install"], {
cwd: projectPath,
env: {
...process.env,
CI: "",
},
})
// Write project name to the package.json.
const packageJsonPath = path.join(projectPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
packageJson.name = projectPath.split("/").pop()
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2)
)
}
// Try git init.
const cwd = process.cwd()
await execa("git", ["--version"], { cwd: projectPath })
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "Initial commit"], {
cwd: projectPath,
})
createSpinner?.succeed("Creating a new Next.js monorepo.")
} catch (error) {
createSpinner?.fail(
"Something went wrong creating a new Next.js monorepo."
)
handleError(error)
}
},
create: async () => {
// Empty for now.
},
files: [
{
type: "registry:page",
path: "app/page.tsx",
target: "app/page.tsx",
content: dedent`import { ComponentExample } from "@/components/component-example";
export default function Page() {
return <ComponentExample />;
}
`,
},
],
})

View File

@@ -0,0 +1,75 @@
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import dedent from "dedent"
import { execa } from "execa"
import { createTemplate } from "./create-template"
export const next = createTemplate({
name: "next",
title: "Next.js",
defaultProjectName: "my-app",
init: async ({ projectPath, packageManager, cwd, srcDir, version }) => {
const createSpinner = spinner(
`Creating a new Next.js project. This may take a few minutes.`
).start()
// Note: pnpm fails here. Fallback to npx with --use-PACKAGE-MANAGER.
const args = [
"--tailwind",
"--eslint",
"--typescript",
"--app",
srcDir ? "--src-dir" : "--no-src-dir",
"--no-import-alias",
`--use-${packageManager}`,
]
if (
version.startsWith("15") ||
version.startsWith("latest") ||
version.startsWith("canary")
) {
args.push("--turbopack")
}
if (version.startsWith("latest") || version.startsWith("canary")) {
args.push("--no-react-compiler")
}
try {
await execa(
"npx",
[`create-next-app@${version}`, projectPath, "--silent", ...args],
{
cwd,
}
)
} catch (error) {
logger.break()
logger.error(
`Something went wrong creating a new Next.js project. Please try again.`
)
process.exit(1)
}
createSpinner?.succeed("Creating a new Next.js project.")
},
create: async () => {
// Empty for now.
},
files: [
{
type: "registry:page",
path: "app/page.tsx",
target: "app/page.tsx",
content: dedent`import { ComponentExample } from "@/components/component-example";
export default function Page() {
return <ComponentExample />;
}
`,
},
],
})

View File

@@ -0,0 +1,118 @@
import os from "os"
import path from "path"
import { handleError } from "@/src/utils/handle-error"
import { spinner } from "@/src/utils/spinner"
import dedent from "dedent"
import { execa } from "execa"
import fs from "fs-extra"
import { GITHUB_TEMPLATE_URL, createTemplate } from "./create-template"
export const start = createTemplate({
name: "start",
title: "TanStack Start",
defaultProjectName: "my-app",
init: async ({ projectPath, packageManager }) => {
const createSpinner = spinner(
`Creating a new TanStack Start project. This may take a few minutes.`
).start()
try {
const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
if (localTemplateDir) {
// Use local template directory for development.
const localTemplatePath = path.resolve(localTemplateDir, "start-app")
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Get the template from GitHub.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await fs.ensureDir(templatePath)
const response = await fetch(GITHUB_TEMPLATE_URL)
if (!response.ok) {
throw new Error(`Failed to download template: ${response.statusText}`)
}
// Write the tar file.
const tarPath = path.resolve(templatePath, "template.tar.gz")
await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
await execa("tar", [
"-xzf",
tarPath,
"-C",
templatePath,
"--strip-components=2",
"ui-main/templates/start-app",
])
const extractedPath = path.resolve(templatePath, "start-app")
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Remove pnpm-lock.yaml if using a different package manager.
if (packageManager !== "pnpm") {
const lockFilePath = path.join(projectPath, "pnpm-lock.yaml")
if (fs.existsSync(lockFilePath)) {
await fs.remove(lockFilePath)
}
}
// Run install.
await execa(packageManager, ["install"], {
cwd: projectPath,
})
// Write project name to the package.json.
const packageJsonPath = path.join(projectPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
packageJson.name = projectPath.split("/").pop()
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2)
)
}
// Try git init.
await execa("git", ["--version"], { cwd: projectPath })
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "Initial commit"], {
cwd: projectPath,
})
createSpinner?.succeed("Creating a new TanStack Start project.")
} catch (error) {
createSpinner?.fail(
"Something went wrong creating a new TanStack Start project."
)
handleError(error)
}
},
create: async () => {
// Empty for now.
},
files: [
{
type: "registry:file",
path: "src/routes/index.tsx",
target: "src/routes/index.tsx",
content: dedent`import { createFileRoute } from "@tanstack/react-router";
import { ComponentExample } from "@/components/component-example";
export const Route = createFileRoute("/")({ component: App });
function App() {
return (
<ComponentExample />
);
}
`,
},
],
})

View File

@@ -0,0 +1,113 @@
import os from "os"
import path from "path"
import { handleError } from "@/src/utils/handle-error"
import { spinner } from "@/src/utils/spinner"
import dedent from "dedent"
import { execa } from "execa"
import fs from "fs-extra"
import { GITHUB_TEMPLATE_URL, createTemplate } from "./create-template"
export const vite = createTemplate({
name: "vite",
title: "Vite",
defaultProjectName: "my-app",
init: async ({ projectPath, packageManager }) => {
const createSpinner = spinner(
`Creating a new Vite project. This may take a few minutes.`
).start()
try {
const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
if (localTemplateDir) {
// Use local template directory for development.
const localTemplatePath = path.resolve(localTemplateDir, "vite-app")
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Get the template from GitHub.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await fs.ensureDir(templatePath)
const response = await fetch(GITHUB_TEMPLATE_URL)
if (!response.ok) {
throw new Error(`Failed to download template: ${response.statusText}`)
}
// Write the tar file.
const tarPath = path.resolve(templatePath, "template.tar.gz")
await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
await execa("tar", [
"-xzf",
tarPath,
"-C",
templatePath,
"--strip-components=2",
"ui-main/templates/vite-app",
])
const extractedPath = path.resolve(templatePath, "vite-app")
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Remove pnpm-lock.yaml if using a different package manager.
if (packageManager !== "pnpm") {
const lockFilePath = path.join(projectPath, "pnpm-lock.yaml")
if (fs.existsSync(lockFilePath)) {
await fs.remove(lockFilePath)
}
}
// Run install.
await execa(packageManager, ["install"], {
cwd: projectPath,
})
// Write project name to the package.json.
const packageJsonPath = path.join(projectPath, "package.json")
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
packageJson.name = projectPath.split("/").pop()
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2)
)
}
// Try git init.
await execa("git", ["--version"], { cwd: projectPath })
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "Initial commit"], {
cwd: projectPath,
})
createSpinner?.succeed("Creating a new Vite project.")
} catch (error) {
createSpinner?.fail("Something went wrong creating a new Vite project.")
handleError(error)
}
},
create: async () => {
// Empty for now.
},
files: [
{
type: "registry:file",
path: "src/App.tsx",
target: "src/App.tsx",
content: dedent`import { ComponentExample } from "@/components/component-example";
export function App() {
return <ComponentExample />;
}
export default App;
`,
},
],
})