mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-26 14:16:08 +00:00
feat(shadcn): add --template flag (#6863)
* feat(shadcn): add --template flag * chore: changeset * fix: type
This commit is contained in:
5
.changeset/metal-queens-hang.md
Normal file
5
.changeset/metal-queens-hang.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": patch
|
||||
---
|
||||
|
||||
add --template flag
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
117
packages/shadcn/src/utils/create-project.test.ts
Normal file
117
packages/shadcn/src/utils/create-project.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user