diff --git a/apps/v4/app/(create)/components/project-form.tsx b/apps/v4/app/(create)/components/project-form.tsx index cd3755c688..e0444c33ab 100644 --- a/apps/v4/app/(create)/components/project-form.tsx +++ b/apps/v4/app/(create)/components/project-form.tsx @@ -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() { - - Create Project - + Create Project Configure your project to use shadcn/ui. - - + - Are you creating a new project? + + Are you creating a new project? + setParams({ new: value === "new" })} @@ -188,7 +187,9 @@ export function ProjectForm() { Yes - I'm creating a new project. + + I'm creating a new project. + @@ -213,7 +214,7 @@ export function ProjectForm() { - + {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 ( - See the installation guides for more templates and frameworks. + See the{" "} + + installation guides + {" "} + for more templates and frameworks. - Do you want to enable RTL? + + Do you want to enable RTL? + setParams({ rtl: value === "yes" })} className="grid grid-cols-2 gap-2" > - + No @@ -287,9 +300,7 @@ export function ProjectForm() { - + Yes @@ -302,19 +313,20 @@ export function ProjectForm() { - To learn more about RTL, see the RTL setup guide - for {selectedTemplate?.title}. + {" "} + for {selectedTemplate?.title}. - - + { diff --git a/apps/v4/app/(create)/create/page.tsx b/apps/v4/app/(create)/create/page.tsx index e6b986025b..6804565153 100644 --- a/apps/v4/app/(create)/create/page.tsx +++ b/apps/v4/app/(create)/create/page.tsx @@ -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" diff --git a/packages/shadcn/src/commands/create.ts b/packages/shadcn/src/commands/create.ts index 38e4853a0a..7a1a6e6cb0 100644 --- a/packages/shadcn/src/commands/create.ts +++ b/packages/shadcn/src/commands/create.ts @@ -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 ; -} - -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 ; -} -`, - }, - ] - 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 ( - - ); -} -`, - }, - ] - default: - return [] - } -} diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts index 66a8450ca2..4f63dfcf0e 100644 --- a/packages/shadcn/src/commands/init.ts +++ b/packages/shadcn/src/commands/init.ts @@ -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 = { "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", diff --git a/packages/shadcn/src/utils/create-project.test.ts b/packages/shadcn/src/utils/create-project.test.ts index d7446ad128..883a222b94 100644 --- a/packages/shadcn/src/utils/create-project.test.ts +++ b/packages/shadcn/src/utils/create-project.test.ts @@ -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 () => { diff --git a/packages/shadcn/src/utils/create-project.ts b/packages/shadcn/src/utils/create-project.ts index 4d78cdc94b..7bad00c075 100644 --- a/packages/shadcn/src/utils/create-project.ts +++ b/packages/shadcn/src/utils/create-project.ts @@ -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, @@ -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) - } -} diff --git a/packages/shadcn/src/utils/templates/create-template.ts b/packages/shadcn/src/utils/templates/create-template.ts new file mode 100644 index 0000000000..4734418740 --- /dev/null +++ b/packages/shadcn/src/utils/templates/create-template.ts @@ -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 + create: (options: TemplateOptions) => Promise + files?: RegistryItem["files"] +}) { + return config +} + +export const GITHUB_TEMPLATE_URL = + "https://codeload.github.com/shadcn-ui/ui/tar.gz/main" diff --git a/packages/shadcn/src/utils/templates/index.ts b/packages/shadcn/src/utils/templates/index.ts new file mode 100644 index 0000000000..30c96dbd29 --- /dev/null +++ b/packages/shadcn/src/utils/templates/index.ts @@ -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 diff --git a/packages/shadcn/src/utils/templates/next-monorepo.ts b/packages/shadcn/src/utils/templates/next-monorepo.ts new file mode 100644 index 0000000000..9a985ae3cc --- /dev/null +++ b/packages/shadcn/src/utils/templates/next-monorepo.ts @@ -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 ; +} +`, + }, + ], +}) diff --git a/packages/shadcn/src/utils/templates/next.ts b/packages/shadcn/src/utils/templates/next.ts new file mode 100644 index 0000000000..71f9b03b60 --- /dev/null +++ b/packages/shadcn/src/utils/templates/next.ts @@ -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 ; +} +`, + }, + ], +}) diff --git a/packages/shadcn/src/utils/templates/start.ts b/packages/shadcn/src/utils/templates/start.ts new file mode 100644 index 0000000000..1aa5e465f8 --- /dev/null +++ b/packages/shadcn/src/utils/templates/start.ts @@ -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 ( + + ); +} +`, + }, + ], +}) diff --git a/packages/shadcn/src/utils/templates/vite.ts b/packages/shadcn/src/utils/templates/vite.ts new file mode 100644 index 0000000000..66fefa66ac --- /dev/null +++ b/packages/shadcn/src/utils/templates/vite.ts @@ -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 ; +} + +export default App; +`, + }, + ], +})