This commit is contained in:
shadcn
2026-02-14 21:01:50 +04:00
parent 525775fb36
commit 1ecc8066db
10 changed files with 246 additions and 218 deletions

View File

@@ -201,8 +201,7 @@ export const create = new Command()
overwrite: true,
})
const selectedTemplate =
templates[template as keyof typeof templates]
const selectedTemplate = templates[template as keyof typeof templates]
if (selectedTemplate?.files?.length) {
await updateFiles(selectedTemplate.files, config, {

View File

@@ -50,22 +50,20 @@ import { spinner } from "@/src/utils/spinner"
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"
process.on("exit", (code) => {
const filePath = path.resolve(process.cwd(), "components.json")
// Delete backup if successful.
if (code === 0) {
return deleteFileBackup(filePath)
}
// Restore backup if error.
return restoreFileBackup(filePath)
})
const DEFAULT_INIT_PRESET = {
style: "nova",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "geist",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
} as const
export const initOptionsSchema = z.object({
cwd: z.string(),
@@ -125,31 +123,22 @@ export const init = new Command()
.option("--no-css-variables", "do not use css variables for theming.")
.option("--rtl", "enable RTL support.", false)
.action(async (components, opts) => {
let componentsJsonBackupPath: string | undefined
try {
// Apply defaults when --defaults flag is set.
if (opts.defaults) {
opts.template = opts.template || "next"
// Use base-nova preset as default.
const initUrl = buildInitUrl(
{
...DEFAULT_INIT_PRESET,
base: "base",
style: "nova",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "geist",
rtl: opts.rtl ?? false,
menuAccent: "subtle",
menuColor: "default",
radius: "default",
},
opts.rtl ?? false
)
components = [initUrl, ...components]
}
// Validate template early.
if (opts.template && !(opts.template in templates)) {
logger.break()
logger.error(
@@ -163,7 +152,6 @@ export const init = new Command()
process.exit(1)
}
// Run early preflight check for existing components.json.
const cwd = path.resolve(opts.cwd)
if (
fsExtra.existsSync(path.resolve(cwd, "components.json")) &&
@@ -183,7 +171,6 @@ export const init = new Command()
process.exit(1)
}
// Handle --preset option.
if (opts.preset !== undefined) {
const presetResult = await handlePresetOption(
opts.preset === true ? true : opts.preset,
@@ -207,11 +194,9 @@ export const init = new Command()
initUrl = buildInitUrl(presetResult, opts.rtl ?? false)
}
// Prepend the preset URL to the components list.
components = [initUrl, ...components]
}
// Prompt for preset when no preset, no components, and no defaults.
if (
opts.preset === undefined &&
components.length === 0 &&
@@ -222,44 +207,16 @@ export const init = new Command()
path.resolve(cwd, "package.json")
)
// Detect framework for existing projects.
let detectedTemplate: string | undefined
if (hasPackageJson) {
const frameworkTemplateMap: Record<string, string> = {
"next-app": "next",
"next-pages": "next",
vite: "vite",
"tanstack-start": "start",
}
const projectInfo = await getProjectInfo(cwd)
if (projectInfo) {
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) {
// Template provided via -t flag, use it directly.
} else 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 {
// Prompt for template only for new projects without -t flag.
if (!opts.template && !hasPackageJson) {
const { template } = await prompts({
type: "select",
name: "template",
message: "Select a template",
choices: templateChoices,
choices: Object.entries(templates).map(([value, t]) => ({
title: t.title,
value,
})),
})
if (!template) {
@@ -314,16 +271,9 @@ export const init = new Command()
// User chose a default base (radix or base).
const initUrl = buildInitUrl(
{
...DEFAULT_INIT_PRESET,
base: preset,
style: "nova",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "geist",
rtl: opts.rtl ?? false,
menuAccent: "subtle",
menuColor: "default",
radius: "default",
},
opts.rtl ?? false
)
@@ -331,10 +281,10 @@ export const init = new Command()
}
const options = initOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
isNewProject: false,
components,
...opts,
cwd: path.resolve(opts.cwd),
installStyleIndex: true,
})
@@ -376,7 +326,13 @@ export const init = new Command()
// Since components.json might not be valid at this point.
// Temporarily rename components.json to allow preflight to run.
// We'll rename it back after preflight.
createFileBackup(componentsJsonPath)
componentsJsonBackupPath =
createFileBackup(componentsJsonPath) ?? undefined
if (!componentsJsonBackupPath) {
logger.warn(
`Could not back up ${highlighter.info("components.json")}.`
)
}
}
// Ensure all registries used in components are configured.
@@ -428,6 +384,11 @@ export const init = new Command()
deleteFileBackup(path.resolve(options.cwd, "components.json"))
logger.break()
} catch (error) {
if (componentsJsonBackupPath) {
restoreFileBackup(
componentsJsonBackupPath.replace(FILE_BACKUP_SUFFIX, "")
)
}
logger.break()
handleError(error)
} finally {
@@ -441,7 +402,7 @@ export async function runInit(
}
) {
let projectInfo
let newProjectTemplate
let newProjectTemplate: keyof typeof templates | undefined
if (!options.skipPreflight) {
const preflight = await preFlightInit(options)
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
@@ -462,140 +423,125 @@ export async function runInit(
}
const selectedTemplate = newProjectTemplate
? templates[newProjectTemplate as keyof typeof templates]
? templates[newProjectTemplate]
: undefined
let result
const components = [
...(options.installStyleIndex ? ["index"] : []),
...(options.components ?? []),
]
if (selectedTemplate?.init) {
const components = [
...(options.installStyleIndex ? ["index"] : []),
...(options.components ?? []),
]
result = await selectedTemplate.init({
const result = await selectedTemplate.init({
projectPath: options.cwd,
components,
registryBaseConfig: options.registryBaseConfig,
rtl: options.rtl ?? false,
silent: options.silent,
})
} else {
const projectConfig = await getProjectConfig(options.cwd, projectInfo)
let config = projectConfig
? await promptForMinimalConfig(projectConfig, options)
: await promptForConfig(await getConfig(options.cwd))
// Run postInit for new projects (e.g. git init).
await selectedTemplate.postInit({ projectPath: options.cwd })
if (!options.yes) {
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: `Write configuration to ${highlighter.info(
"components.json"
)}. Proceed?`,
initial: true,
})
return result
}
if (!proceed) {
process.exit(0)
}
}
// Standard init path for existing projects.
const projectConfig = await getProjectConfig(options.cwd, projectInfo)
// Prepare the list of components to be added.
const components = [
// "index" is the default shadcn style.
// Why index? Because when style is true, we read style from components.json and fetch that.
// i.e new-york from components.json then fetch /styles/new-york/index.
// TODO: Fix this so that we can extend any style i.e --style=new-york.
...(options.installStyleIndex ? ["index"] : []),
...(options.components ?? []),
]
let config = projectConfig
? await promptForMinimalConfig(projectConfig, options)
: await promptForConfig(await getConfig(options.cwd))
// Ensure registries are configured for the components we're about to add.
const fullConfigForRegistry = await resolveConfigPaths(options.cwd, config)
const { config: configWithRegistries } = await ensureRegistriesInConfig(
components,
fullConfigForRegistry,
{
silent: true,
}
)
// Update config with any new registries found.
if (configWithRegistries.registries) {
config.registries = configWithRegistries.registries
}
const componentSpinner = spinner(`Writing components.json.`).start()
const targetPath = path.resolve(options.cwd, "components.json")
const backupPath = `${targetPath}${FILE_BACKUP_SUFFIX}`
// Merge and keep registries at the end.
const mergeConfig = (base: typeof config, override: object) => {
const { registries, ...merged } = deepmerge(base, override)
return { ...merged, registries } as typeof config
}
// Merge with backup config if it exists.
if (fsExtra.existsSync(backupPath)) {
const existingConfig = await fsExtra.readJson(backupPath)
if (options.force) {
// With --force, only preserve registries from existing config.
if (existingConfig.registries) {
config.registries = {
...existingConfig.registries,
...(config.registries || {}),
}
}
} else {
config = mergeConfig(existingConfig, config)
}
}
// Merge config from registry:base item.
if (options.registryBaseConfig) {
config = mergeConfig(config, options.registryBaseConfig)
}
// Ensure rtl is set from CLI option (takes priority over registryBaseConfig).
if (options.rtl !== undefined) {
config.rtl = options.rtl
}
// Make sure to filter out built-in registries.
// TODO: fix this in ensureRegistriesInConfig.
config.registries = Object.fromEntries(
Object.entries(config.registries || {}).filter(
([key]) => !Object.keys(BUILTIN_REGISTRIES).includes(key)
)
)
// Write components.json.
await fs.writeFile(
targetPath,
`${JSON.stringify(config, null, 2)}\n`,
"utf8"
)
componentSpinner.succeed()
// Add components.
const fullConfig = await resolveConfigPaths(options.cwd, config)
await addComponents(components, fullConfig, {
// Init will always overwrite files.
overwrite: true,
silent: options.silent,
isNewProject:
options.isNewProject || projectInfo?.framework.name === "next-app",
if (!options.yes) {
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: `Write configuration to ${highlighter.info(
"components.json"
)}. Proceed?`,
initial: true,
})
result = fullConfig
if (!proceed) {
process.exit(0)
}
}
// Run postInit for new projects.
if (selectedTemplate?.postInit) {
await selectedTemplate.postInit({ projectPath: options.cwd })
// Ensure registries are configured for the components we're about to add.
const fullConfigForRegistry = await resolveConfigPaths(options.cwd, config)
const { config: configWithRegistries } = await ensureRegistriesInConfig(
components,
fullConfigForRegistry,
{
silent: true,
}
)
// Update config with any new registries found.
if (configWithRegistries.registries) {
config.registries = configWithRegistries.registries
}
return result
const componentSpinner = spinner(`Writing components.json.`).start()
const targetPath = path.resolve(options.cwd, "components.json")
const backupPath = `${targetPath}${FILE_BACKUP_SUFFIX}`
// Merge and keep registries at the end.
const mergeConfig = (base: typeof config, override: object) => {
const { registries, ...merged } = deepmerge(base, override)
return { ...merged, registries } as typeof config
}
// Merge with backup config if it exists.
if (fsExtra.existsSync(backupPath)) {
const existingConfig = await fsExtra.readJson(backupPath)
if (options.force) {
// With --force, only preserve registries from existing config.
if (existingConfig.registries) {
config.registries = {
...existingConfig.registries,
...(config.registries || {}),
}
}
} else {
config = mergeConfig(existingConfig, config)
}
}
// Merge config from registry:base item.
if (options.registryBaseConfig) {
config = mergeConfig(config, options.registryBaseConfig)
}
// Ensure rtl is set from CLI option (takes priority over registryBaseConfig).
if (options.rtl !== undefined) {
config.rtl = options.rtl
}
// Make sure to filter out built-in registries.
// TODO: fix this in ensureRegistriesInConfig.
config.registries = Object.fromEntries(
Object.entries(config.registries || {}).filter(
([key]) => !Object.keys(BUILTIN_REGISTRIES).includes(key)
)
)
// Write components.json.
await fs.writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf8")
componentSpinner.succeed()
// Add components.
const fullConfig = await resolveConfigPaths(options.cwd, config)
await addComponents(components, fullConfig, {
// Init will always overwrite files.
overwrite: true,
silent: options.silent,
isNewProject:
options.isNewProject || projectInfo?.framework.name === "next-app",
})
return fullConfig
}
async function promptForConfig(defaultConfig: Config | null = null) {
@@ -692,6 +638,10 @@ async function promptForConfig(defaultConfig: Config | null = null) {
},
])
if (!options.style) {
process.exit(0)
}
return rawConfigSchema.parse({
$schema: "https://ui.shadcn.com/schema.json",
style: options.style,

View File

@@ -32,6 +32,8 @@ export function createTemplate(config: {
}
}
// 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 })

View File

@@ -4,10 +4,7 @@ import { start } from "./start"
import { vite } from "./vite"
export { createTemplate, GITHUB_TEMPLATE_URL } from "./create-template"
export type {
TemplateInitOptions,
TemplateOptions,
} from "./create-template"
export type { TemplateInitOptions, TemplateOptions } from "./create-template"
export const templates = {
next,

View File

@@ -80,7 +80,9 @@ export const next = createTemplate({
createSpinner?.succeed("Creating a new Next.js project.")
} catch (error) {
createSpinner?.fail("Something went wrong creating a new Next.js project.")
createSpinner?.fail(
"Something went wrong creating a new Next.js project."
)
handleError(error)
}
},

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,28 @@
{
"name": "my-remix-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "remix vite:dev",
"build": "remix vite:build"
},
"dependencies": {
"@remix-run/node": "^2.0.0",
"@remix-run/react": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.527.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.6",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -57,11 +57,7 @@ describe("shadcn init - next-app", () => {
it("should init without CSS variables", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, [
"init",
"--defaults",
"--no-css-variables",
])
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"])
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
@@ -399,6 +395,44 @@ describe("shadcn init - custom style", async () => {
})
})
describe("shadcn init - unsupported framework", () => {
it("should init with --defaults on unsupported framework", async () => {
const fixturePath = await createFixtureTestDirectory("remix-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
const componentsJsonPath = path.join(fixturePath, "components.json")
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
const componentsJson = await fs.readJson(componentsJsonPath)
expect(componentsJson).toMatchObject({
style: "base-nova",
tailwind: {
baseColor: "neutral",
cssVariables: true,
},
})
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(
true
)
})
it("should init with --defaults and components on unsupported framework", async () => {
const fixturePath = await createFixtureTestDirectory("remix-app")
await npxShadcn(fixturePath, ["init", "--defaults", "button"])
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
const cssPath = path.join(fixturePath, "app/globals.css")
const cssContent = await fs.readFile(cssPath, "utf-8")
expect(cssContent).toContain("@layer base")
expect(cssContent).toContain("--background")
expect(cssContent).toContain("--foreground")
})
})
describe("shadcn init - template flag", () => {
it("should reject invalid template", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
@@ -410,12 +444,7 @@ describe("shadcn init - template flag", () => {
it("should accept valid template with --defaults", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, [
"init",
"-t",
"next",
"--defaults",
])
await npxShadcn(fixturePath, ["init", "-t", "next", "--defaults"])
const componentsJsonPath = path.join(fixturePath, "components.json")
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
@@ -445,19 +474,17 @@ describe("shadcn init - --name flag", () => {
const emptyDir = path.join(testBaseDir, "empty-next")
await fs.ensureDir(emptyDir)
await npxShadcn(
emptyDir,
["init", "--defaults", "--name", projectName],
{ timeout: 120000 }
)
await npxShadcn(emptyDir, ["init", "--defaults", "--name", projectName], {
timeout: 120000,
})
const projectPath = path.join(emptyDir, projectName)
// Verify project was created with the correct name.
expect(await fs.pathExists(projectPath)).toBe(true)
expect(
await fs.pathExists(path.join(projectPath, "package.json"))
).toBe(true)
expect(await fs.pathExists(path.join(projectPath, "package.json"))).toBe(
true
)
// Verify components.json was created.
const componentsJsonPath = path.join(projectPath, "components.json")
@@ -525,11 +552,7 @@ describe("shadcn init - existing components.json", () => {
await fs.writeJson(componentsJsonPath, config)
// Reinit with --force.
await npxShadcn(fixturePath, [
"init",
"--force",
"--defaults",
])
await npxShadcn(fixturePath, ["init", "--force", "--defaults"])
const newConfig = await fs.readJson(componentsJsonPath)
expect(newConfig.style).toBe("new-york")