feat(shadcn): add --template flag (#6863)

* feat(shadcn): add --template flag

* chore: changeset

* fix: type
This commit is contained in:
shadcn
2025-03-05 15:13:34 +04:00
committed by GitHub
parent a3fe5074c1
commit c16c58d0f9
5 changed files with 171 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
add --template flag

View File

@@ -149,7 +149,7 @@ export const add = new Command()
let shouldUpdateAppIndex = false
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
const { projectPath, projectType } = await createProject({
const { projectPath, template } = await createProject({
cwd: options.cwd,
force: options.overwrite,
srcDir: options.srcDir,
@@ -161,7 +161,7 @@ export const add = new Command()
}
options.cwd = projectPath
if (projectType === "monorepo") {
if (template === "next-monorepo") {
options.cwd = path.resolve(options.cwd, "apps/web")
config = await getConfig(options.cwd)
} else {

View File

@@ -3,7 +3,7 @@ import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { getRegistryBaseColors, getRegistryStyles } from "@/src/registry/api"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { TEMPLATES, createProject } from "@/src/utils/create-project"
import * as ERRORS from "@/src/utils/errors"
import {
DEFAULT_COMPONENTS,
@@ -39,6 +39,20 @@ export const initOptionsSchema = z.object({
isNewProject: z.boolean(),
srcDir: z.boolean().optional(),
cssVariables: z.boolean(),
template: z
.string()
.optional()
.refine(
(val) => {
if (val) {
return TEMPLATES[val as keyof typeof TEMPLATES]
}
return true
},
{
message: "Invalid template. Please use 'next' or 'next-monorepo'.",
}
),
})
export const init = new Command()
@@ -48,6 +62,11 @@ export const init = new Command()
"[components...]",
"the components to add or a url to the component."
)
.option(
"-t, --template <template>",
"the template to use. (next, next-monorepo)",
""
)
.option("-y, --yes", "skip confirmation prompt.", true)
.option("-d, --defaults,", "use default configuration.", false)
.option("-f, --force", "force overwrite of existing configuration.", false)
@@ -97,24 +116,24 @@ export async function runInit(
}
) {
let projectInfo
let newProjectType
let newProjectTemplate
if (!options.skipPreflight) {
const preflight = await preFlightInit(options)
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
const { projectPath, projectType } = await createProject(options)
const { projectPath, template } = await createProject(options)
if (!projectPath) {
process.exit(1)
}
options.cwd = projectPath
options.isNewProject = true
newProjectType = projectType
newProjectTemplate = template
}
projectInfo = preflight.projectInfo
} else {
projectInfo = await getProjectInfo(options.cwd)
}
if (newProjectType === "monorepo") {
if (newProjectTemplate === "next-monorepo") {
options.cwd = path.resolve(options.cwd, "apps/web")
return await getConfig(options.cwd)
}

View File

@@ -0,0 +1,117 @@
import { fetchRegistry } from "@/src/registry/api"
import { execa } from "execa"
import fs from "fs-extra"
import prompts from "prompts"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { TEMPLATES, createProject } from "./create-project"
// Mock dependencies
vi.mock("fs-extra")
vi.mock("execa")
vi.mock("prompts")
vi.mock("@/src/registry/api")
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageManager: vi.fn().mockResolvedValue("npm"),
}))
describe("createProject", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fs.access).mockResolvedValue(undefined)
vi.mocked(fs.existsSync).mockReturnValue(false)
})
afterEach(() => {
vi.resetAllMocks()
})
it("should create a Next.js project with default options", async () => {
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
const result = await createProject({
cwd: "/test",
force: false,
srcDir: false,
})
expect(result).toEqual({
projectPath: "/test/my-app",
projectName: "my-app",
template: TEMPLATES.next,
})
expect(execa).toHaveBeenCalledWith(
"npx",
expect.arrayContaining(["create-next-app@latest", "/test/my-app"]),
expect.any(Object)
)
})
it("should create a monorepo project when selected", async () => {
vi.mocked(prompts).mockResolvedValue({
type: "next-monorepo",
name: "my-monorepo",
})
const result = await createProject({
cwd: "/test",
force: false,
srcDir: false,
})
expect(result).toEqual({
projectPath: "/test/my-monorepo",
projectName: "my-monorepo",
template: TEMPLATES["next-monorepo"],
})
})
it("should handle remote components and force next template", async () => {
vi.mocked(fetchRegistry).mockResolvedValue([
{
meta: { nextVersion: "13.0.0" },
},
])
const result = await createProject({
cwd: "/test",
force: true,
components: ["/chat/b/some-component"],
})
expect(result.template).toBe(TEMPLATES.next)
})
it("should throw error if project path already exists", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "existing-app" })
const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
await createProject({
cwd: "/test",
force: false,
})
expect(mockExit).toHaveBeenCalledWith(1)
})
it("should throw error if path is not writable", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("Permission denied"))
vi.mocked(prompts).mockResolvedValue({ type: "next", name: "my-app" })
const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
await createProject({
cwd: "/test",
force: false,
})
expect(mockExit).toHaveBeenCalledWith(1)
})
})

View File

@@ -15,10 +15,15 @@ import { z } from "zod"
const MONOREPO_TEMPLATE_URL =
"https://codeload.github.com/shadcn-ui/ui/tar.gz/main"
export const TEMPLATES = {
next: "next",
"next-monorepo": "next-monorepo",
} as const
export async function createProject(
options: Pick<
z.infer<typeof initOptionsSchema>,
"cwd" | "force" | "srcDir" | "components"
"cwd" | "force" | "srcDir" | "components" | "template"
>
) {
options = {
@@ -26,9 +31,13 @@ export async function createProject(
...options,
}
let projectType: "next" | "monorepo" = "next"
let projectName: string = "my-app"
let nextVersion = "canary"
let template: keyof typeof TEMPLATES =
options.template && TEMPLATES[options.template as keyof typeof TEMPLATES]
? (options.template as keyof typeof TEMPLATES)
: "next"
let projectName: string =
template === TEMPLATES.next ? "my-app" : "my-monorepo"
let nextVersion = "latest"
const isRemoteComponent =
options.components?.length === 1 &&
@@ -45,6 +54,9 @@ export async function createProject(
})
.parse(result)
nextVersion = meta.nextVersion
// Force template to next for remote components.
template = TEMPLATES.next
} catch (error) {
logger.break()
handleError(error)
@@ -54,14 +66,14 @@ export async function createProject(
if (!options.force) {
const { type, name } = await prompts([
{
type: "select",
type: options.template || isRemoteComponent ? null : "select",
name: "type",
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: "monorepo" },
{ title: "Next.js (Monorepo)", value: "next-monorepo" },
],
initial: 0,
},
@@ -78,7 +90,7 @@ export async function createProject(
},
])
projectType = type
template = type ?? template
projectName = name
}
@@ -113,7 +125,7 @@ export async function createProject(
process.exit(1)
}
if (projectType === "next") {
if (template === TEMPLATES.next) {
await createNextProject(projectPath, {
version: nextVersion,
cwd: options.cwd,
@@ -122,7 +134,7 @@ export async function createProject(
})
}
if (projectType === "monorepo") {
if (template === TEMPLATES["next-monorepo"]) {
await createMonorepoProject(projectPath, {
packageManager,
})
@@ -131,7 +143,7 @@ export async function createProject(
return {
projectPath,
projectName,
projectType,
template,
}
}
@@ -161,6 +173,7 @@ async function createNextProject(
if (
options.version.startsWith("15") ||
options.version.startsWith("latest") ||
options.version.startsWith("canary")
) {
args.push("--turbopack")