feat: wip for init

This commit is contained in:
shadcn
2026-02-12 11:37:35 +04:00
parent 4dbc5581a5
commit a12dd019d3
21 changed files with 1860 additions and 1668 deletions

View File

@@ -68,7 +68,7 @@ export function TemplatePicker({
value={params.template}
onValueChange={(value) => {
setParams({
template: value as "next" | "start" | "vite",
template: value as "next" | "next-monorepo" | "start" | "vite",
})
}}
>

View File

@@ -1,17 +1,17 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {
ComputerTerminal01Icon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import * as React from "react"
import { toast } from "sonner"
import { useConfig } from "@/hooks/use-config"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { useConfig } from "@/hooks/use-config"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Dialog,
@@ -41,12 +41,6 @@ import {
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
const TEMPLATES = [
{
@@ -54,6 +48,11 @@ const TEMPLATES = [
title: "Next.js",
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Next.js</title><path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z" fill="currentColor"/></svg>',
},
{
value: "next-monorepo",
title: "Next.js (Monorepo)",
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Next.js</title><path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z" fill="currentColor"/></svg>',
},
{
value: "start",
title: "TanStack Start",
@@ -180,10 +179,10 @@ export function ToolbarControls() {
value={params.template}
onValueChange={(value) => {
setParams({
template: value as "next" | "start" | "vite",
template: value as "next" | "next-monorepo" | "start" | "vite",
})
}}
className="grid grid-cols-3 gap-2"
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((template) => (
<FieldLabel
@@ -217,7 +216,7 @@ export function ToolbarControls() {
<FieldTitle>Enable RTL</FieldTitle>
<FieldDescription>
<a
href={`/docs/rtl/${params.template}`}
href={`/docs/rtl/${params.template === "next-monorepo" ? "next" : params.template}`}
className="text-foreground underline"
target="_blank"
rel="noopener noreferrer"
@@ -243,33 +242,26 @@ export function ToolbarControls() {
}}
className="bg-surface min-w-0 gap-0 overflow-hidden rounded-lg border"
>
<div className="flex items-center gap-2 p-2">
<div className="flex items-center gap-2 px-1.5 py-1">
<TabsList className="*:data-[slot=tabs-trigger]:data-[state=active]:border-input h-auto rounded-none bg-transparent p-0 font-mono group-data-[orientation=horizontal]/tabs:h-8 *:data-[slot=tabs-trigger]:h-7 *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none!">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
onClick={handleCopyFromTabs}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
</TooltipContent>
</Tooltip>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
onClick={handleCopyFromTabs}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (

View File

@@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
menuAccent: searchParams.get("menuAccent"),
menuColor: searchParams.get("menuColor"),
radius: searchParams.get("radius"),
template: searchParams.get("template"),
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
})

View File

@@ -63,6 +63,7 @@ const designSystemSearchParams = {
).withDefault("default"),
template: parseAsStringLiteral([
"next",
"next-monorepo",
"start",
"vite",
] as const).withDefault("next"),

View File

@@ -94,7 +94,7 @@ export const designSystemConfigSchema = z
radius: z
.enum(RADII.map((r) => r.name) as [RadiusValue, ...RadiusValue[]])
.default("default"),
template: z.enum(["next", "start", "vite"]).default("next").optional(),
template: z.enum(["next", "next-monorepo", "start", "vite"]).default("next").optional(),
})
.refine(
(data) => {
@@ -433,7 +433,7 @@ export function buildRegistryBase(config: DesignSystemConfig) {
},
},
...(config.rtl && {
docs: `To learn how to set up the RTL provider and fonts for your app, see https://ui.shadcn.com/docs/rtl/${config.template ?? "next"}`,
docs: `To learn how to set up the RTL provider and fonts for your app, see https://ui.shadcn.com/docs/rtl/${config.template === "next-monorepo" ? "next" : (config.template ?? "next")}`,
}),
}
}

View File

@@ -61,7 +61,7 @@
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist && rimraf components",
"start:dev": "cross-env REGISTRY_URL=http://localhost:4000/r node dist/index.js",
"start:dev": "cross-env REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates node dist/index.js",
"start:prod": "cross-env REGISTRY_URL=https://ui.shadcn.com/r node dist/index.js",
"start": "node dist/index.js",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
@@ -71,7 +71,7 @@
"pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next",
"pub:release": "pnpm build && pnpm publish --access public",
"test": "vitest run",
"test:dev": "REGISTRY_URL=http://localhost:4000/r vitest run",
"test:dev": "REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates vitest run",
"mcp:inspect": "pnpm dlx @modelcontextprotocol/inspector node dist/index.js mcp"
},
"dependencies": {

View File

@@ -23,6 +23,7 @@ import { initOptionsSchema, runInit } from "./init"
const CREATE_TEMPLATES = {
next: "Next.js",
"next-monorepo": "Next.js (Monorepo)",
vite: "Vite",
start: "TanStack Start",
} as const
@@ -35,7 +36,7 @@ export const create = new Command()
.argument("[name]", "the name of your project")
.option(
"-t, --template <template>",
"the template to use. e.g. next, start or vite"
"the template to use. e.g. next, next-monorepo, start or vite"
)
.option("-p, --preset [name]", "use a preset configuration")
.option(
@@ -265,6 +266,7 @@ export default App;
},
]
case "next":
case "next-monorepo":
return [
{
type: "registry:page" as const,

View File

@@ -13,6 +13,7 @@ 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 { initMonorepoProject } from "@/src/utils/init-monorepo"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import {
@@ -398,8 +399,35 @@ export async function runInit(
}
if (newProjectTemplate === "next-monorepo") {
options.cwd = path.resolve(options.cwd, "apps/web")
return await getConfig(options.cwd)
// Prompt for base color if not set.
if (!options.baseColor) {
const baseColors = await getRegistryBaseColors()
const { tailwindBaseColor } = await prompts({
type: "select",
name: "tailwindBaseColor",
message: "Which color would you like to use as the base color?",
choices: baseColors.map((color) => ({
title: color.label,
value: color.name,
})),
})
options.baseColor = tailwindBaseColor
}
const components = [
...(options.baseStyle ? ["index"] : []),
...(options.components ?? []),
]
return await initMonorepoProject({
projectPath: options.cwd,
components,
baseStyle: options.baseStyle,
baseColor: options.baseColor ?? "neutral",
registryBaseConfig: options.registryBaseConfig,
rtl: options.rtl ?? false,
silent: options.silent,
})
}
const projectConfig = await getProjectConfig(options.cwd, projectInfo)

View File

@@ -317,56 +317,58 @@ async function addWorkspaceComponents(
rootSpinner?.succeed()
// Sort files.
filesCreated.sort()
filesUpdated.sort()
filesSkipped.sort()
// Deduplicate and sort files.
const dedupedCreated = Array.from(new Set(filesCreated)).sort()
const dedupedUpdated = Array.from(
new Set(filesUpdated.filter((file) => !filesCreated.includes(file)))
).sort()
const dedupedSkipped = Array.from(new Set(filesSkipped)).sort()
const hasUpdatedFiles = filesCreated.length || filesUpdated.length
if (!hasUpdatedFiles && !filesSkipped.length) {
const hasUpdatedFiles = dedupedCreated.length || dedupedUpdated.length
if (!hasUpdatedFiles && !dedupedSkipped.length) {
spinner(`No files updated.`, {
silent: options.silent,
})?.info()
}
if (filesCreated.length) {
if (dedupedCreated.length) {
spinner(
`Created ${filesCreated.length} ${
filesCreated.length === 1 ? "file" : "files"
`Created ${dedupedCreated.length} ${
dedupedCreated.length === 1 ? "file" : "files"
}:`,
{
silent: options.silent,
}
)?.succeed()
for (const file of filesCreated) {
for (const file of dedupedCreated) {
logger.log(` - ${file}`)
}
}
if (filesUpdated.length) {
if (dedupedUpdated.length) {
spinner(
`Updated ${filesUpdated.length} ${
filesUpdated.length === 1 ? "file" : "files"
`Updated ${dedupedUpdated.length} ${
dedupedUpdated.length === 1 ? "file" : "files"
}:`,
{
silent: options.silent,
}
)?.info()
for (const file of filesUpdated) {
for (const file of dedupedUpdated) {
logger.log(` - ${file}`)
}
}
if (filesSkipped.length) {
if (dedupedSkipped.length) {
spinner(
`Skipped ${filesSkipped.length} ${
filesUpdated.length === 1 ? "file" : "files"
`Skipped ${dedupedSkipped.length} ${
dedupedSkipped.length === 1 ? "file" : "files"
}: (use --overwrite to overwrite)`,
{
silent: options.silent,
}
)?.info()
for (const file of filesSkipped) {
for (const file of dedupedSkipped) {
logger.log(` - ${file}`)
}
}

View File

@@ -237,36 +237,51 @@ async function createMonorepoProject(
).start()
try {
// Get the template.
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}`)
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)
}
// 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.
// Run install. Disable frozen lockfile since the template's lockfile may not match.
await execa(options.packageManager, ["install"], {
cwd: projectPath,
env: {
...process.env,
CI: "",
},
})
// await execa("cd", [cwd])
// Write project name to the package.json
// 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")
@@ -302,28 +317,40 @@ async function createViteProject(
).start()
try {
// Get the template.
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}`)
}
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)
// 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") {
@@ -373,28 +400,40 @@ async function createStartProject(
).start()
try {
// Get the template.
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}`)
}
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)
// 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") {

View File

@@ -0,0 +1,116 @@
import path from "path"
import { iconLibraries, type IconLibraryName } from "@/src/icons/libraries"
import { configWithDefaults } from "@/src/registry/config"
import { resolveRegistryTree } from "@/src/registry/resolver"
import { rawConfigSchema } from "@/src/schema"
import { addComponents } from "@/src/utils/add-components"
import { resolveConfigPaths } from "@/src/utils/get-config"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { updateCssVars } from "@/src/utils/updaters/update-css-vars"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import { updateFonts } from "@/src/utils/updaters/update-fonts"
import deepmerge from "deepmerge"
import fsExtra from "fs-extra"
export async function initMonorepoProject(options: {
projectPath: string
components: string[]
baseStyle: boolean
baseColor: string
registryBaseConfig?: Record<string, unknown>
rtl: boolean
silent: boolean
}) {
const packagesUiPath = path.resolve(options.projectPath, "packages/ui")
const appsWebPath = path.resolve(options.projectPath, "apps/web")
// Update packages/ui/components.json.
const packagesUiConfigPath = path.resolve(packagesUiPath, "components.json")
let packagesUiConfig = await fsExtra.readJson(packagesUiConfigPath)
if (options.registryBaseConfig) {
packagesUiConfig = deepmerge(packagesUiConfig, options.registryBaseConfig)
}
packagesUiConfig.tailwind.baseColor = options.baseColor
if (options.rtl) {
packagesUiConfig.rtl = true
}
await fsExtra.writeJson(packagesUiConfigPath, packagesUiConfig, {
spaces: 2,
})
// Update apps/web/components.json.
const appsWebConfigPath = path.resolve(appsWebPath, "components.json")
let appsWebConfig = await fsExtra.readJson(appsWebConfigPath)
if (options.registryBaseConfig) {
appsWebConfig = deepmerge(appsWebConfig, options.registryBaseConfig)
}
appsWebConfig.tailwind.baseColor = options.baseColor
if (options.rtl) {
appsWebConfig.rtl = true
}
await fsExtra.writeJson(appsWebConfigPath, appsWebConfig, { spaces: 2 })
// Apply preset CSS/style to packages/ui directly.
// We use the packages/ui config (not apps/web) so addProjectComponents runs
// instead of addWorkspaceComponents. This keeps CSS/deps in packages/ui.
const resolvedPackagesUiConfig = await resolveConfigPaths(
packagesUiPath,
rawConfigSchema.parse(packagesUiConfig)
)
const { config: packagesUiWithRegistries } = await ensureRegistriesInConfig(
options.components,
resolvedPackagesUiConfig,
{ silent: true }
)
await addComponents(options.components, packagesUiWithRegistries, {
overwrite: true,
silent: options.silent,
isNewProject: true,
})
const resolvedAppsWebConfig = await resolveConfigPaths(
appsWebPath,
rawConfigSchema.parse(appsWebConfig)
)
// Handle fonts at the apps/web level.
// packages/ui has no next.config so massageTreeForFonts can't detect Next.js.
// We resolve the tree to get fonts, then apply them using the apps/web config
// which has next.config and layout.tsx.
const tree = await resolveRegistryTree(
options.components,
configWithDefaults(packagesUiWithRegistries)
)
if (tree?.fonts?.length) {
const [fontSans] = tree.fonts
// Add font CSS variable to packages/ui CSS (same as massageTreeForFonts for Next.js).
await updateCssVars(
{ theme: { [fontSans.font.variable]: `var(${fontSans.font.variable})` } },
resolvedPackagesUiConfig,
{
silent: options.silent,
overwriteCssVars: false,
}
)
// Update layout.tsx in apps/web with the font import and className.
await updateFonts(tree.fonts, resolvedAppsWebConfig, {
silent: options.silent,
})
}
// Install icon library packages in both workspaces.
const iconLibrary = resolvedPackagesUiConfig.iconLibrary as IconLibraryName
if (iconLibrary && iconLibrary in iconLibraries) {
const iconPackages = [...iconLibraries[iconLibrary].packages]
await updateDependencies(iconPackages, [], resolvedPackagesUiConfig, {
silent: true,
})
await updateDependencies(iconPackages, [], resolvedAppsWebConfig, {
silent: true,
})
}
return resolvedAppsWebConfig
}

View File

@@ -251,19 +251,18 @@ export async function updateFiles(
// Let's update filesUpdated with the updated files.
filesUpdated.push(...updatedFiles)
// If a file is in filesCreated and filesUpdated, we should remove it from filesUpdated.
filesUpdated = filesUpdated.filter((file) => !filesCreated.includes(file))
// Remove duplicates and filter out files already in filesCreated.
filesCreated = Array.from(new Set(filesCreated))
filesUpdated = Array.from(
new Set(filesUpdated.filter((file) => !filesCreated.includes(file)))
)
filesSkipped = Array.from(new Set(filesSkipped))
const hasUpdatedFiles = filesCreated.length || filesUpdated.length
if (!hasUpdatedFiles && !filesSkipped.length) {
filesCreatedSpinner?.info("No files updated.")
}
// Remove duplicates.
filesCreated = Array.from(new Set(filesCreated))
filesUpdated = Array.from(new Set(filesUpdated))
filesSkipped = Array.from(new Set(filesSkipped))
if (filesCreated.length) {
filesCreatedSpinner?.succeed(
`Created ${filesCreated.length} ${

View File

@@ -0,0 +1,209 @@
import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import { cssHasProperties, npxShadcn } from "../utils/helpers"
import { TEMP_DIR } from "../utils/setup"
// These tests download the monorepo template from GitHub and install dependencies.
// They require network access and a running local registry at REGISTRY_URL.
describe("shadcn create - next-monorepo", () => {
it("should create a monorepo project with preset", async () => {
const projectName = `test-monorepo-preset-${process.pid}`
const result = await npxShadcn(
TEMP_DIR,
[
"create",
projectName,
"--template",
"next-monorepo",
"--preset",
"radix-nova",
],
{ timeout: 120000 }
)
const projectPath = path.join(TEMP_DIR, projectName)
// Verify project structure exists.
expect(await fs.pathExists(projectPath)).toBe(true)
expect(
await fs.pathExists(path.join(projectPath, "packages/ui/components.json"))
).toBe(true)
expect(
await fs.pathExists(path.join(projectPath, "apps/web/components.json"))
).toBe(true)
// Verify packages/ui/components.json is updated with preset config.
const uiConfig = await fs.readJson(
path.join(projectPath, "packages/ui/components.json")
)
expect(uiConfig.style).toBe("radix-nova")
expect(uiConfig.iconLibrary).toBe("hugeicons")
expect(uiConfig.tailwind.css).toBe("src/styles/globals.css")
expect(uiConfig.tailwind.baseColor).toBe("neutral")
expect(uiConfig.tailwind.cssVariables).toBe(true)
expect(uiConfig.aliases.components).toBe("@workspace/ui/components")
expect(uiConfig.aliases.utils).toBe("@workspace/ui/lib/utils")
// Verify apps/web/components.json is updated with preset config.
const webConfig = await fs.readJson(
path.join(projectPath, "apps/web/components.json")
)
expect(webConfig.style).toBe("radix-nova")
expect(webConfig.iconLibrary).toBe("hugeicons")
expect(webConfig.tailwind.css).toBe(
"../../packages/ui/src/styles/globals.css"
)
expect(webConfig.tailwind.baseColor).toBe("neutral")
// Verify workspace aliases are preserved.
expect(webConfig.aliases.components).toBe("@/components")
expect(webConfig.aliases.utils).toBe("@workspace/ui/lib/utils")
expect(webConfig.aliases.ui).toBe("@workspace/ui/components")
// Verify CSS was applied to packages/ui.
const cssPath = path.join(projectPath, "packages/ui/src/styles/globals.css")
expect(await fs.pathExists(cssPath)).toBe(true)
const cssContent = await fs.readFile(cssPath, "utf-8")
expect(cssContent).toContain("@layer base")
expect(cssContent).toContain(":root")
expect(cssContent).toContain(".dark")
expect(cssContent).toContain("--background")
expect(cssContent).toContain("--foreground")
expect(cssContent).toContain("--primary")
// Verify component-example was added to apps/web.
expect(
await fs.pathExists(
path.join(projectPath, "apps/web/components/component-example.tsx")
)
).toBe(true)
// Verify page.tsx was written.
const pageContent = await fs.readFile(
path.join(projectPath, "apps/web/app/page.tsx"),
"utf-8"
)
expect(pageContent).toContain("ComponentExample")
expect(pageContent).toContain(
'import { ComponentExample } from "@/components/component-example"'
)
})
it("should create a monorepo with custom base color and font", async () => {
const projectName = `test-monorepo-custom-${process.pid}`
// Use radix-maia preset which has figtree font and hugeicons.
const result = await npxShadcn(
TEMP_DIR,
[
"create",
projectName,
"--template",
"next-monorepo",
"--preset",
"radix-maia",
],
{ timeout: 120000 }
)
const projectPath = path.join(TEMP_DIR, projectName)
expect(await fs.pathExists(projectPath)).toBe(true)
// Verify preset config is applied to both components.json files.
const uiConfig = await fs.readJson(
path.join(projectPath, "packages/ui/components.json")
)
expect(uiConfig.style).toBe("radix-maia")
expect(uiConfig.iconLibrary).toBe("hugeicons")
expect(uiConfig.tailwind.baseColor).toBe("neutral")
const webConfig = await fs.readJson(
path.join(projectPath, "apps/web/components.json")
)
expect(webConfig.style).toBe("radix-maia")
expect(webConfig.iconLibrary).toBe("hugeicons")
// Verify CSS file has theme variables.
const cssPath = path.join(projectPath, "packages/ui/src/styles/globals.css")
const cssContent = await fs.readFile(cssPath, "utf-8")
expect(cssContent).toContain("@layer base")
expect(cssContent).toContain(":root")
expect(cssContent).toContain(".dark")
expect(cssContent).toContain("--background")
expect(cssContent).toContain("--foreground")
// Verify font registry dependency was installed (figtree font).
expect(cssContent).toContain("--font-sans")
})
it("should create a monorepo with custom preset url", async () => {
const projectName = `test-monorepo-url-${process.pid}`
// Build a custom init URL with specific options.
const registryUrl = process.env.REGISTRY_URL || "http://localhost:4000/r"
const baseUrl = registryUrl.replace(/\/r\/?$/, "")
const initUrl = `${baseUrl}/init?base=radix&style=nova&baseColor=zinc&theme=zinc&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default&template=next`
const result = await npxShadcn(
TEMP_DIR,
[
"create",
projectName,
"--template",
"next-monorepo",
"--preset",
initUrl,
],
{ timeout: 120000 }
)
const projectPath = path.join(TEMP_DIR, projectName)
expect(await fs.pathExists(projectPath)).toBe(true)
// Verify config reflects the custom URL params.
const uiConfig = await fs.readJson(
path.join(projectPath, "packages/ui/components.json")
)
expect(uiConfig.style).toBe("radix-nova")
expect(uiConfig.iconLibrary).toBe("lucide")
expect(uiConfig.tailwind.baseColor).toBe("zinc")
const webConfig = await fs.readJson(
path.join(projectPath, "apps/web/components.json")
)
expect(webConfig.style).toBe("radix-nova")
expect(webConfig.tailwind.baseColor).toBe("zinc")
// Verify CSS has zinc color theme applied.
const cssPath = path.join(projectPath, "packages/ui/src/styles/globals.css")
const cssContent = await fs.readFile(cssPath, "utf-8")
expect(cssContent).toContain(":root")
expect(cssContent).toContain(".dark")
expect(cssContent).toContain("--background")
expect(cssContent).toContain("--foreground")
expect(
cssHasProperties(cssContent, [
{
selector: ":root",
properties: {
"--background": "oklch(1 0 0)",
},
},
])
).toBe(true)
// Verify component-example and page.tsx.
expect(
await fs.pathExists(
path.join(projectPath, "apps/web/components/component-example.tsx")
)
).toBe(true)
const pageContent = await fs.readFile(
path.join(projectPath, "apps/web/app/page.tsx"),
"utf-8"
)
expect(pageContent).toContain("ComponentExample")
})
})

View File

@@ -9,6 +9,7 @@ import { TEMP_DIR } from "./setup"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.join(__dirname, "../../fixtures")
const SHADCN_CLI_PATH = path.join(__dirname, "../../../shadcn/dist/index.js")
const TEMPLATES_DIR = path.join(__dirname, "../../../../templates")
export function getRegistryUrl() {
return process.env.REGISTRY_URL || "http://localhost:4000/r"
@@ -32,6 +33,7 @@ export async function runCommand(
options?: {
env?: Record<string, string>
input?: string
timeout?: number
}
) {
try {
@@ -45,7 +47,7 @@ export async function runCommand(
},
input: options?.input,
reject: false,
timeout: 30000,
timeout: options?.timeout ?? 30000,
})
const result = await childProcess
@@ -69,14 +71,18 @@ export async function npxShadcn(
args: string[],
{
debug = false,
timeout,
}: {
debug?: boolean
timeout?: number
} = {}
) {
const result = await runCommand(cwd, args, {
env: {
REGISTRY_URL: getRegistryUrl(),
SHADCN_TEMPLATE_DIR: TEMPLATES_DIR,
},
timeout,
})
if (debug) {

View File

@@ -13,7 +13,6 @@
},
"dependencies": {
"@workspace/ui": "workspace:*",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "^19.2.4",

View File

@@ -7,15 +7,9 @@
"lint": "eslint . --max-warnings 0"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@@ -1,60 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@workspace/ui/lib/utils"
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -2,126 +2,3 @@
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

File diff suppressed because it is too large Load Diff