-
-
Notifications
You must be signed in to change notification settings - Fork 9.9k
Expand file tree
/
Copy pathcreate-template.ts
More file actions
322 lines (290 loc) · 9.69 KB
/
Copy pathcreate-template.ts
File metadata and controls
322 lines (290 loc) · 9.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import os from "os"
import path from "path"
import type { RegistryItem } from "@/src/registry/schema"
import type { Config } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { spinner } from "@/src/utils/spinner"
import { execa } from "execa"
import fs from "fs-extra"
const GITHUB_REPO_URL =
process.env.SHADCN_GITHUB_URL ?? "https://github.com/shadcn-ui/ui.git"
export interface TemplateOptions {
projectPath: string
packageManager: string
cwd: string
}
export interface TemplateInitOptions {
projectPath: string
components: string[]
registryBaseConfig?: Record<string, unknown>
rtl: boolean
menuColor?: string
menuAccent?: string
iconLibrary?: string
silent: boolean
}
export interface TemplateConfig {
name: string
title: string
description?: string
defaultProjectName: string
// The template directory name (e.g. "next-app", "vite-app").
templateDir: string
// Framework names that map to this template.
frameworks?: string[]
scaffold?: (options: TemplateOptions) => Promise<void>
create: (options: TemplateOptions) => Promise<void>
init?: (options: TemplateInitOptions) => Promise<Config>
files?: RegistryItem["files"]
postInit?: (options: { projectPath: string }) => Promise<void>
// Monorepo overrides. When --monorepo is passed, these fields
// are merged over the base template config.
monorepo?: {
templateDir: string
defaultProjectName?: string
init?: (options: TemplateInitOptions) => Promise<Config>
files?: RegistryItem["files"]
}
}
export function createTemplate(config: TemplateConfig) {
return {
...config,
frameworks: config.frameworks ?? [],
scaffold:
config.scaffold ??
defaultScaffold({
title: config.title,
templateDir: config.templateDir,
}),
postInit: config.postInit ?? defaultPostInit,
}
}
// Resolve effective template config for --monorepo mode.
export function resolveTemplate(
template: ReturnType<typeof createTemplate>,
{ monorepo }: { monorepo?: boolean }
) {
if (!monorepo || !template.monorepo) {
return template
}
const m = template.monorepo
const resolved = {
...template,
templateDir: m.templateDir,
defaultProjectName: m.defaultProjectName ?? m.templateDir,
init: m.init ?? template.init,
files: m.files ?? template.files,
}
// Rebuild scaffold with monorepo overrides.
resolved.scaffold = defaultScaffold({
title: template.title,
templateDir: m.templateDir,
})
return resolved
}
// Get the appropriate install args for the given package manager.
function getInstallArgs(packageManager: string): string[] {
switch (packageManager) {
case "pnpm":
// pnpm enables frozen lockfile in CI by default.
// The template lockfile may drift, so force-disable it explicitly.
return ["--no-frozen-lockfile"]
default:
return []
}
}
// Adapt a pnpm-based monorepo template to the target package manager.
async function adaptWorkspaceConfig(
projectPath: string,
packageManager: string
) {
if (packageManager === "pnpm") {
return
}
const pnpmWorkspacePath = path.join(projectPath, "pnpm-workspace.yaml")
const packageJsonPath = path.join(projectPath, "package.json")
// Remove pnpm-lock.yaml.
const lockFilePath = path.join(projectPath, "pnpm-lock.yaml")
if (fs.existsSync(lockFilePath)) {
await fs.remove(lockFilePath)
}
const isMonorepo = fs.existsSync(pnpmWorkspacePath)
// Update root package.json: update "packageManager" field for the
// target package manager, and add "workspaces" for npm/bun/yarn.
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
const packageJson = JSON.parse(packageJsonContent)
if (isMonorepo) {
// Monorepo templates use turbo which requires packageManager.
// Replace the pnpm value with the target package manager.
packageJson.packageManager =
await getPackageManagerVersion(packageManager)
} else {
delete packageJson.packageManager
}
if (isMonorepo) {
// Read workspace patterns from pnpm-workspace.yaml.
const workspaceContent = await fs.readFile(pnpmWorkspacePath, "utf8")
const patterns: string[] = []
for (const line of workspaceContent.split("\n")) {
const match = line.match(/^\s*-\s*["']?(.+?)["']?\s*$/)
if (match) {
patterns.push(match[1])
}
}
packageJson.workspaces = patterns
await fs.remove(pnpmWorkspacePath)
}
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + "\n"
)
}
// Rewrite workspace: protocol references in nested package.json files.
// npm does not support workspace: protocol; bun and yarn do, so only
// rewrite for npm monorepo templates.
if (isMonorepo && packageManager === "npm") {
await rewriteWorkspaceProtocol(projectPath)
}
}
// Get the package manager name and version string (e.g. "bun@1.2.0").
async function getPackageManagerVersion(packageManager: string) {
try {
const { stdout } = await execa(packageManager, ["--version"])
return `${packageManager}@${stdout.trim()}`
} catch {
return `${packageManager}@*`
}
}
// Recursively find all package.json files and replace workspace: protocol
// version specifiers with "*", which npm understands.
async function rewriteWorkspaceProtocol(dir: string) {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === "node_modules") continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await rewriteWorkspaceProtocol(fullPath)
} else if (entry.name === "package.json") {
const content = await fs.readFile(fullPath, "utf8")
if (!content.includes("workspace:")) continue
const pkg = JSON.parse(content)
let changed = false
for (const depKey of [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
]) {
const deps = pkg[depKey]
if (!deps) continue
for (const [name, version] of Object.entries(deps)) {
if (typeof version === "string" && version.startsWith("workspace:")) {
deps[name] = "*"
changed = true
}
}
}
if (changed) {
await fs.writeFile(fullPath, JSON.stringify(pkg, null, 2) + "\n")
}
}
}
}
// Default scaffold that downloads a template from GitHub.
function defaultScaffold({
title,
templateDir,
}: {
title: string
templateDir: string
}) {
return async ({ projectPath, packageManager }: TemplateOptions) => {
const createSpinner = spinner(
`Creating a new ${title} 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, templateDir)
await fs.copy(localTemplatePath, projectPath, {
filter: (src) => !src.includes("node_modules"),
})
} else {
// Clone only the template directory from GitHub using sparse checkout.
const templatePath = path.join(
os.tmpdir(),
`shadcn-template-${Date.now()}`
)
await execa("git", [
"clone",
"--depth",
"1",
"--filter=blob:none",
"--sparse",
GITHUB_REPO_URL,
templatePath,
])
await execa("git", [
"-C",
templatePath,
"sparse-checkout",
"set",
`templates/${templateDir}`,
])
const extractedPath = path.resolve(
templatePath,
"templates",
templateDir
)
await fs.move(extractedPath, projectPath)
await fs.remove(templatePath)
}
// Adapt workspace config and lockfiles for the target package manager.
await adaptWorkspaceConfig(projectPath, packageManager)
// Write project name to the package.json and inject pnpm configuration.
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 = path.basename(projectPath)
if (packageManager === "pnpm") {
packageJson.pnpm = packageJson.pnpm || {}
packageJson.pnpm.onlyBuiltDependencies =
packageJson.pnpm.onlyBuiltDependencies || []
if (!packageJson.pnpm.onlyBuiltDependencies.includes("esbuild")) {
packageJson.pnpm.onlyBuiltDependencies.push("esbuild")
}
}
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + "\n"
)
}
// Run install.
const installArgs = getInstallArgs(packageManager)
const args = ["install", ...installArgs]
await execa(packageManager, args, {
cwd: projectPath,
})
createSpinner?.succeed(`Creating a new ${title} project.`)
} catch (error) {
createSpinner?.fail(
`Something went wrong creating a new ${title} project.`
)
handleError(error)
}
}
}
// Initialize a git repository and create an initial commit.
// Silently ignores failures (e.g. git not installed).
async function defaultPostInit({ projectPath }: { projectPath: string }) {
try {
await execa("git", ["init"], { cwd: projectPath })
await execa("git", ["add", "-A"], { cwd: projectPath })
await execa("git", ["commit", "-m", "feat: initial commit"], {
cwd: projectPath,
})
} catch {}
}