mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-25 13:46:07 +00:00
Merge pull request #10451 from shadcn-ui/shadcn/apply-only
feat: add apply --only
This commit is contained in:
5
.changeset/tricky-seas-shine.md
Normal file
5
.changeset/tricky-seas-shine.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add apply --only
|
||||
59
apps/v4/app/(create)/init/route.test.ts
Normal file
59
apps/v4/app/(create)/init/route.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { buildRegistryBase, DEFAULT_CONFIG } from "@/registry/config"
|
||||
|
||||
import { GET } from "./route"
|
||||
|
||||
function createRequest(search = "") {
|
||||
const searchParams = new URLSearchParams(
|
||||
Object.entries(DEFAULT_CONFIG).map(([key, value]) => [key, String(value)])
|
||||
)
|
||||
const url = new URL(`http://localhost:4000/init${search}`)
|
||||
|
||||
for (const [key, value] of url.searchParams) {
|
||||
searchParams.set(key, value)
|
||||
}
|
||||
|
||||
return {
|
||||
nextUrl: new URL(`http://localhost:4000/init?${searchParams}`),
|
||||
} as Parameters<typeof GET>[0]
|
||||
}
|
||||
|
||||
describe("GET /init", () => {
|
||||
it("returns the full registry base when only is omitted", async () => {
|
||||
const response = await GET(createRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json).toEqual(buildRegistryBase(DEFAULT_CONFIG))
|
||||
})
|
||||
|
||||
it("returns a sparse registry base when only is provided", async () => {
|
||||
const response = await GET(createRequest("?only=theme"))
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json.type).toBe("registry:base")
|
||||
expect(json.config).toEqual({
|
||||
menuColor: "default",
|
||||
menuAccent: "subtle",
|
||||
tailwind: {
|
||||
baseColor: "neutral",
|
||||
},
|
||||
})
|
||||
expect(json.cssVars.light).toBeDefined()
|
||||
expect(json.cssVars.light.radius).toBe("0.625rem")
|
||||
expect(json.dependencies).toBeUndefined()
|
||||
expect(json.registryDependencies).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects unsupported only values", async () => {
|
||||
const response = await GET(createRequest("?only=icon"))
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(json.error).toBe(
|
||||
"Invalid only value. Use one or more of: theme, font"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,11 @@ import { track } from "@vercel/analytics/server"
|
||||
import { isPresetCode } from "shadcn/preset"
|
||||
import { registryItemSchema } from "shadcn/schema"
|
||||
|
||||
import { buildRegistryBase } from "@/registry/config"
|
||||
import {
|
||||
buildPartialRegistryBase,
|
||||
buildRegistryBase,
|
||||
parseRegistryBaseParts,
|
||||
} from "@/registry/config"
|
||||
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
|
||||
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
|
||||
|
||||
@@ -16,13 +20,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const onlyResult = parseRegistryBaseParts(searchParams.get("only"))
|
||||
if (!onlyResult.success) {
|
||||
return NextResponse.json({ error: onlyResult.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const rawPreset = searchParams.get("preset")
|
||||
const presetCode =
|
||||
rawPreset && isPresetCode(rawPreset)
|
||||
? rawPreset
|
||||
: getPresetCode(result.data)
|
||||
|
||||
const registryBase = buildRegistryBase(result.data)
|
||||
const registryBase = onlyResult.parts
|
||||
? buildPartialRegistryBase(result.data, onlyResult.parts)
|
||||
: buildRegistryBase(result.data)
|
||||
const parseResult = registryItemSchema.safeParse(registryBase)
|
||||
|
||||
if (!parseResult.success) {
|
||||
|
||||
@@ -93,6 +93,14 @@ Use the `apply` command to apply a preset to an existing project.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
```
|
||||
|
||||
You can apply only the theme or fonts from a preset without reinstalling UI components:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply --preset a2r6bw --only theme
|
||||
```
|
||||
|
||||
Supported values for `--only` are `theme` and `font`.
|
||||
|
||||
**Options**
|
||||
|
||||
```bash
|
||||
@@ -105,6 +113,7 @@ Arguments:
|
||||
|
||||
Options:
|
||||
--preset <preset> preset configuration to apply
|
||||
--only [parts] apply only parts of a preset: theme, font
|
||||
-y, --yes skip confirmation prompt. (default: false)
|
||||
-c, --cwd <cwd> the working directory. defaults to the current directory.
|
||||
-s, --silent mute output. (default: false)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildPartialRegistryBase,
|
||||
buildRegistryBase,
|
||||
DEFAULT_CONFIG,
|
||||
designSystemConfigSchema,
|
||||
parseRegistryBaseParts,
|
||||
} from "./config"
|
||||
|
||||
describe("buildRegistryBase", () => {
|
||||
@@ -100,3 +102,105 @@ describe("buildRegistryBase", () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseRegistryBaseParts", () => {
|
||||
it("returns undefined parts when only is omitted", () => {
|
||||
expect(parseRegistryBaseParts(null)).toEqual({
|
||||
success: true,
|
||||
parts: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("normalizes and dedupes only values", () => {
|
||||
expect(parseRegistryBaseParts("theme,fonts,font")).toEqual({
|
||||
success: true,
|
||||
parts: ["theme", "font"],
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects invalid only values", () => {
|
||||
const result = parseRegistryBaseParts("theme,colors")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildPartialRegistryBase", () => {
|
||||
it("builds a sparse theme payload", () => {
|
||||
const result = buildPartialRegistryBase(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
baseColor: "taupe",
|
||||
theme: "taupe",
|
||||
chartColor: "taupe",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
radius: "large",
|
||||
},
|
||||
["theme"]
|
||||
)
|
||||
|
||||
expect(result.type).toBe("registry:base")
|
||||
expect(result.extends).toBe("none")
|
||||
expect(result.config).toEqual({
|
||||
menuColor: "inverted",
|
||||
menuAccent: "bold",
|
||||
tailwind: {
|
||||
baseColor: "taupe",
|
||||
},
|
||||
})
|
||||
expect(result.cssVars?.light?.radius).toBe("0.875rem")
|
||||
expect(result.cssVars?.light).toBeDefined()
|
||||
expect(result.cssVars?.dark).toBeDefined()
|
||||
expect(result.registryDependencies).toBeUndefined()
|
||||
expect("dependencies" in result).toBe(false)
|
||||
})
|
||||
|
||||
it("builds a sparse font payload", () => {
|
||||
const result = buildPartialRegistryBase(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
font: "noto-sans",
|
||||
fontHeading: "playfair-display",
|
||||
},
|
||||
["font"]
|
||||
)
|
||||
|
||||
expect(result.registryDependencies).toEqual([
|
||||
"font-noto-sans",
|
||||
"font-heading-playfair-display",
|
||||
])
|
||||
expect(result.cssVars).toBeUndefined()
|
||||
expect(result.config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("adds a heading fallback when font heading inherits", () => {
|
||||
const result = buildPartialRegistryBase(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
font: "jetbrains-mono",
|
||||
fontHeading: "inherit",
|
||||
},
|
||||
["font"]
|
||||
)
|
||||
|
||||
expect(result.registryDependencies).toEqual(["font-jetbrains-mono"])
|
||||
expect(result.cssVars?.theme?.["--font-heading"]).toBe("var(--font-mono)")
|
||||
})
|
||||
|
||||
it("merges combined partial payloads", () => {
|
||||
const result = buildPartialRegistryBase(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
font: "figtree",
|
||||
},
|
||||
["theme", "font"]
|
||||
)
|
||||
|
||||
expect(result.config?.tailwind?.baseColor).toBe("neutral")
|
||||
expect(result.registryDependencies).toEqual(["font-figtree"])
|
||||
expect(result.cssVars?.light?.radius).toBe("0.625rem")
|
||||
expect(result.cssVars?.light).toBeDefined()
|
||||
expect(result.cssVars?.theme?.["--font-heading"]).toBe("var(--font-sans)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type IconLibrary,
|
||||
type IconLibraryName,
|
||||
} from "shadcn/icons"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
import { BASE_COLORS, type BaseColor } from "@/registry/base-colors"
|
||||
@@ -25,6 +26,8 @@ export type StyleName = Style["name"]
|
||||
export type ThemeName = Theme["name"]
|
||||
export type BaseColorName = BaseColor["name"]
|
||||
export type ChartColorName = Theme["name"]
|
||||
export const REGISTRY_BASE_PARTS = ["theme", "font"] as const
|
||||
export type RegistryBasePart = (typeof REGISTRY_BASE_PARTS)[number]
|
||||
|
||||
// Derive font values from registry fonts (e.g., "font-inter" -> "inter").
|
||||
const fontValues = bodyFonts.map((f) => f.name.replace("font-", "")) as [
|
||||
@@ -476,6 +479,35 @@ export function getIconLibrary(name: IconLibraryName) {
|
||||
return iconLibraries[name]
|
||||
}
|
||||
|
||||
export function parseRegistryBaseParts(value: string | null) {
|
||||
if (value === null) {
|
||||
return { success: true as const, parts: undefined }
|
||||
}
|
||||
|
||||
const aliases: Record<string, RegistryBasePart> = {
|
||||
theme: "theme",
|
||||
font: "font",
|
||||
fonts: "font",
|
||||
}
|
||||
const rawParts = value
|
||||
.split(",")
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const invalid = rawParts.filter((part) => !aliases[part])
|
||||
|
||||
if (!rawParts.length || invalid.length) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: `Invalid only value. Use one or more of: ${REGISTRY_BASE_PARTS.join(", ")}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
parts: Array.from(new Set(rawParts.map((part) => aliases[part]))),
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a registry:theme item from a design system config.
|
||||
export function buildRegistryTheme(config: DesignSystemConfig) {
|
||||
const baseColor = getBaseColor(config.baseColor)
|
||||
@@ -614,3 +646,68 @@ export function buildRegistryBase(config: DesignSystemConfig) {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPartialRegistryBase(
|
||||
config: DesignSystemConfig,
|
||||
parts: RegistryBasePart[]
|
||||
) {
|
||||
const uniqueParts = Array.from(new Set(parts))
|
||||
const normalizedFontHeading =
|
||||
config.fontHeading === config.font ? "inherit" : config.fontHeading
|
||||
const partialConfig: {
|
||||
menuColor?: DesignSystemConfig["menuColor"]
|
||||
menuAccent?: DesignSystemConfig["menuAccent"]
|
||||
tailwind?: {
|
||||
baseColor?: string
|
||||
}
|
||||
} = {}
|
||||
const registryDependencies: string[] = []
|
||||
const cssVars: NonNullable<RegistryItem["cssVars"]> = {}
|
||||
|
||||
if (uniqueParts.includes("theme")) {
|
||||
const registryTheme = buildRegistryTheme(config)
|
||||
|
||||
partialConfig.menuColor = config.menuColor
|
||||
partialConfig.menuAccent = config.menuAccent
|
||||
partialConfig.tailwind = {
|
||||
baseColor: config.baseColor,
|
||||
}
|
||||
|
||||
if (registryTheme.cssVars.theme) {
|
||||
cssVars.theme = {
|
||||
...(cssVars.theme ?? {}),
|
||||
...registryTheme.cssVars.theme,
|
||||
}
|
||||
}
|
||||
cssVars.light = {
|
||||
...(cssVars.light ?? {}),
|
||||
...registryTheme.cssVars.light,
|
||||
}
|
||||
cssVars.dark = {
|
||||
...(cssVars.dark ?? {}),
|
||||
...registryTheme.cssVars.dark,
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueParts.includes("font")) {
|
||||
registryDependencies.push(`font-${config.font}`)
|
||||
|
||||
if (normalizedFontHeading !== "inherit") {
|
||||
registryDependencies.push(`font-heading-${normalizedFontHeading}`)
|
||||
} else {
|
||||
cssVars.theme = {
|
||||
...(cssVars.theme ?? {}),
|
||||
"--font-heading": getInheritedHeadingFontValue(config.font),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${config.base}-${config.style}-${uniqueParts.join("-")}`,
|
||||
extends: "none",
|
||||
type: "registry:base" as const,
|
||||
...(Object.keys(partialConfig).length > 0 && { config: partialConfig }),
|
||||
...(registryDependencies.length > 0 && { registryDependencies }),
|
||||
...(Object.keys(cssVars).length > 0 && { cssVars }),
|
||||
} satisfies RegistryItem
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveApplyInitUrl } from "./apply"
|
||||
import {
|
||||
getPresetUrlOnly,
|
||||
parseApplyOnlyParts,
|
||||
resolveApplyInitUrl,
|
||||
resolveApplyOnly,
|
||||
validateApplyOnlyPreset,
|
||||
} from "./apply"
|
||||
|
||||
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
|
||||
|
||||
@@ -46,4 +52,128 @@ describe("resolveApplyInitUrl", () => {
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should include only for preset codes", () => {
|
||||
const initUrl = resolveApplyInitUrl("a0", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
only: "theme,font",
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("theme,font")
|
||||
})
|
||||
|
||||
it("should include only for named presets", () => {
|
||||
const initUrl = resolveApplyInitUrl("lyra", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
only: "font",
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("font")
|
||||
})
|
||||
|
||||
it("should include only for raw preset URLs", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
|
||||
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
only: "theme",
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("theme")
|
||||
})
|
||||
|
||||
it("should preserve only from raw preset URLs", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default&only=font`
|
||||
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("font")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseApplyOnlyParts", () => {
|
||||
it("returns undefined when only is omitted", () => {
|
||||
expect(resolveApplyOnly(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects missing only values with allowed values", () => {
|
||||
expect(() => resolveApplyOnly(true)).toThrow(
|
||||
[
|
||||
"Missing value for --only.",
|
||||
"Use one or more of: theme, font.",
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
})
|
||||
|
||||
it("normalizes explicit values and aliases", () => {
|
||||
expect(resolveApplyOnly("font")).toEqual(["font"])
|
||||
expect(parseApplyOnlyParts("theme,font")).toEqual(["theme", "font"])
|
||||
})
|
||||
|
||||
it("dedupes and accepts plural aliases", () => {
|
||||
expect(parseApplyOnlyParts("theme,fonts,font")).toEqual(["theme", "font"])
|
||||
})
|
||||
|
||||
it("rejects invalid values", () => {
|
||||
expect(() => parseApplyOnlyParts("theme,colors")).toThrow(
|
||||
[
|
||||
"Invalid value for --only: theme,colors.",
|
||||
"Use one or more of: theme, font.",
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
expect(() => parseApplyOnlyParts("")).toThrow("Invalid value for --only")
|
||||
expect(() => parseApplyOnlyParts("icon")).toThrow(
|
||||
"Use one or more of: theme, font."
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPresetUrlOnly", () => {
|
||||
it("reads only from shadcn init URLs", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&only=font`
|
||||
|
||||
expect(getPresetUrlOnly(presetUrl)).toBe("font")
|
||||
})
|
||||
|
||||
it("reads only from non-shadcn init URLs", () => {
|
||||
const presetUrl =
|
||||
"http://localhost:4000/init?base=radix&style=nova&only=font"
|
||||
|
||||
expect(getPresetUrlOnly(presetUrl)).toBe("font")
|
||||
expect(resolveApplyOnly(getPresetUrlOnly(presetUrl))).toEqual(["font"])
|
||||
})
|
||||
|
||||
it("ignores only on non-init URLs", () => {
|
||||
const presetUrl =
|
||||
"http://localhost:4000/r/styles/nova/button.json?only=font"
|
||||
|
||||
expect(getPresetUrlOnly(presetUrl)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateApplyOnlyPreset", () => {
|
||||
it("rejects only without a preset", () => {
|
||||
expect(() => validateApplyOnlyPreset({ only: ["theme"] })).toThrow(
|
||||
[
|
||||
"Missing preset for --only.",
|
||||
"Use: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
})
|
||||
|
||||
it("allows only with a preset", () => {
|
||||
expect(() =>
|
||||
validateApplyOnlyPreset({ preset: "a0", only: ["theme"] })
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,15 +33,27 @@ export const applyOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
positionalPreset: z.string().optional(),
|
||||
preset: z.string().optional(),
|
||||
only: z.union([z.boolean(), z.string()]).optional(),
|
||||
yes: z.boolean(),
|
||||
silent: z.boolean(),
|
||||
})
|
||||
|
||||
const APPLY_ONLY_VALUES = ["theme", "font"] as const
|
||||
type ApplyOnlyValue = (typeof APPLY_ONLY_VALUES)[number]
|
||||
|
||||
class ApplyOnlyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "ApplyOnlyError"
|
||||
}
|
||||
}
|
||||
|
||||
export const apply = new Command()
|
||||
.name("apply")
|
||||
.description("apply a preset to an existing project")
|
||||
.argument("[preset]", "the preset to apply")
|
||||
.option("--preset <preset>", "preset configuration to apply")
|
||||
.option("--only [parts]", "apply only parts of a preset: theme, font")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
@@ -58,6 +70,8 @@ export const apply = new Command()
|
||||
})
|
||||
|
||||
const preset = resolveApplyPreset(options)
|
||||
const explicitOnly = resolveApplyOnly(options.only)
|
||||
validateApplyOnlyPreset({ preset, only: explicitOnly })
|
||||
|
||||
const preflight = await preFlightApply(options)
|
||||
|
||||
@@ -114,26 +128,43 @@ export const apply = new Command()
|
||||
|
||||
validatePreset(preset)
|
||||
|
||||
const reinstallComponents = await getProjectComponents(options.cwd)
|
||||
const only = explicitOnly ?? resolveApplyOnly(getPresetUrlOnly(preset))
|
||||
const shouldReinstallComponents = !only
|
||||
const reinstallComponents = shouldReinstallComponents
|
||||
? await getProjectComponents(options.cwd)
|
||||
: []
|
||||
|
||||
if (!options.yes) {
|
||||
logger.break()
|
||||
logger.warn(
|
||||
highlighter.warn(
|
||||
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
|
||||
if (!only) {
|
||||
logger.warn(
|
||||
highlighter.warn(
|
||||
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
highlighter.warn(
|
||||
`Applying the selected preset parts will update your project configuration and styles.`
|
||||
)
|
||||
)
|
||||
}
|
||||
logger.warn(
|
||||
`Commit or stash your changes before continuing so you can easily go back.`
|
||||
)
|
||||
logger.break()
|
||||
logger.log(" The following components will be re-installed:")
|
||||
if (reinstallComponents.length) {
|
||||
for (let i = 0; i < reinstallComponents.length; i += 8) {
|
||||
logger.log(` - ${reinstallComponents.slice(i, i + 8).join(", ")}`)
|
||||
|
||||
if (shouldReinstallComponents) {
|
||||
logger.break()
|
||||
logger.log(" The following components will be re-installed:")
|
||||
if (reinstallComponents.length) {
|
||||
for (let i = 0; i < reinstallComponents.length; i += 8) {
|
||||
logger.log(
|
||||
` - ${reinstallComponents.slice(i, i + 8).join(", ")}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.log(" - No installed UI components were detected.")
|
||||
}
|
||||
} else {
|
||||
logger.log(" - No installed UI components were detected.")
|
||||
}
|
||||
logger.break()
|
||||
|
||||
@@ -156,6 +187,7 @@ export const apply = new Command()
|
||||
const initUrl = resolveApplyInitUrl(preset, currentBase, {
|
||||
template,
|
||||
rtl,
|
||||
only: only?.join(","),
|
||||
})
|
||||
|
||||
await withFileBackup(
|
||||
@@ -171,17 +203,23 @@ export const apply = new Command()
|
||||
| undefined,
|
||||
})
|
||||
|
||||
const applyRegistryBaseConfig = resolveApplyRegistryBaseConfig({
|
||||
registryBaseConfig,
|
||||
existingConfig,
|
||||
only,
|
||||
})
|
||||
|
||||
await runInit({
|
||||
cwd: options.cwd,
|
||||
yes: true,
|
||||
force: false,
|
||||
reinstall: true,
|
||||
reinstall: shouldReinstallComponents,
|
||||
defaults: false,
|
||||
silent: options.silent,
|
||||
isNewProject: false,
|
||||
cssVariables: true,
|
||||
installStyleIndex,
|
||||
registryBaseConfig,
|
||||
registryBaseConfig: applyRegistryBaseConfig,
|
||||
existingConfig,
|
||||
components: [cleanUrl, ...reinstallComponents],
|
||||
})
|
||||
@@ -201,6 +239,14 @@ export const apply = new Command()
|
||||
logger.log("Preset applied successfully.")
|
||||
logger.break()
|
||||
} catch (error) {
|
||||
if (error instanceof ApplyOnlyError) {
|
||||
for (const line of error.message.split("\n")) {
|
||||
logger.error(line)
|
||||
}
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logger.break()
|
||||
handleError(error)
|
||||
} finally {
|
||||
@@ -225,6 +271,118 @@ function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
|
||||
return flagPreset ?? positionalPreset
|
||||
}
|
||||
|
||||
export function getPresetUrlOnly(preset: string) {
|
||||
if (!isUrl(preset)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = new URL(preset)
|
||||
if (url.pathname !== "/init") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return url.searchParams.get("only") ?? undefined
|
||||
}
|
||||
|
||||
export function resolveApplyOnly(
|
||||
value: z.infer<typeof applyOptionsSchema>["only"]
|
||||
) {
|
||||
if (value === undefined || value === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value === true) {
|
||||
throw new ApplyOnlyError(
|
||||
[
|
||||
"Missing value for --only.",
|
||||
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
return parseApplyOnlyParts(value)
|
||||
}
|
||||
|
||||
export function parseApplyOnlyParts(value: string) {
|
||||
const aliases: Record<string, ApplyOnlyValue> = {
|
||||
theme: "theme",
|
||||
font: "font",
|
||||
fonts: "font",
|
||||
}
|
||||
const parts = value
|
||||
.split(",")
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const invalid = parts.filter((part) => !aliases[part])
|
||||
|
||||
if (!parts.length || invalid.length) {
|
||||
throw new ApplyOnlyError(
|
||||
[
|
||||
`Invalid value for --only: ${value}.`,
|
||||
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
return Array.from(new Set(parts.map((part) => aliases[part])))
|
||||
}
|
||||
|
||||
export function validateApplyOnlyPreset(options: {
|
||||
preset?: string
|
||||
only?: ApplyOnlyValue[]
|
||||
}) {
|
||||
if (!options.only || options.preset) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new ApplyOnlyError(
|
||||
[
|
||||
"Missing preset for --only.",
|
||||
"Use: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplyRegistryBaseConfig(options: {
|
||||
registryBaseConfig: Record<string, unknown> | undefined
|
||||
existingConfig: Record<string, unknown>
|
||||
only: ApplyOnlyValue[] | undefined
|
||||
}) {
|
||||
if (!options.only || options.only.includes("theme")) {
|
||||
return options.registryBaseConfig
|
||||
}
|
||||
|
||||
const existingTailwind =
|
||||
typeof options.existingConfig.tailwind === "object" &&
|
||||
options.existingConfig.tailwind !== null
|
||||
? options.existingConfig.tailwind
|
||||
: {}
|
||||
const registryTailwind =
|
||||
typeof options.registryBaseConfig?.tailwind === "object" &&
|
||||
options.registryBaseConfig.tailwind !== null
|
||||
? options.registryBaseConfig.tailwind
|
||||
: {}
|
||||
const config: Record<string, unknown> = {
|
||||
...options.registryBaseConfig,
|
||||
tailwind: {
|
||||
...existingTailwind,
|
||||
...registryTailwind,
|
||||
},
|
||||
}
|
||||
|
||||
if (options.existingConfig.menuColor) {
|
||||
config.menuColor = options.existingConfig.menuColor
|
||||
}
|
||||
|
||||
if (options.existingConfig.menuAccent) {
|
||||
config.menuAccent = options.existingConfig.menuAccent
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function validatePreset(preset: string) {
|
||||
if (isUrl(preset) || isPresetCode(preset)) {
|
||||
return
|
||||
@@ -251,7 +409,7 @@ async function resolveApplyTemplate(cwd: string) {
|
||||
export function resolveApplyInitUrl(
|
||||
preset: string,
|
||||
currentBase: "radix" | "base",
|
||||
options: { template?: string; rtl?: boolean } = {}
|
||||
options: { template?: string; rtl?: boolean; only?: string } = {}
|
||||
) {
|
||||
if (isUrl(preset)) {
|
||||
const url = new URL(preset)
|
||||
@@ -262,6 +420,9 @@ export function resolveApplyInitUrl(
|
||||
|
||||
url.searchParams.set("base", currentBase)
|
||||
url.searchParams.set("rtl", String(options.rtl ?? false))
|
||||
if (options.only) {
|
||||
url.searchParams.set("only", options.only)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
@@ -280,7 +441,7 @@ export function resolveApplyInitUrl(
|
||||
base: currentBase,
|
||||
rtl: options.rtl ?? false,
|
||||
},
|
||||
{ preset, template: options.template }
|
||||
{ preset, template: options.template, only: options.only }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,7 +453,7 @@ export function resolveApplyInitUrl(
|
||||
base: currentBase,
|
||||
rtl: options.rtl ?? resolvedPreset.rtl,
|
||||
},
|
||||
{ template: options.template }
|
||||
{ template: options.template, only: options.only }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ export function resolveInitUrl(
|
||||
menuColor: string
|
||||
radius: string
|
||||
},
|
||||
options?: { template?: string; preset?: string }
|
||||
options?: { template?: string; preset?: string; only?: string }
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
base: preset.base,
|
||||
@@ -228,6 +228,10 @@ export function resolveInitUrl(
|
||||
params.set("template", options.template)
|
||||
}
|
||||
|
||||
if (options?.only) {
|
||||
params.set("only", options.only)
|
||||
}
|
||||
|
||||
// Signal the server to record this init run.
|
||||
params.set("track", "1")
|
||||
|
||||
|
||||
@@ -173,8 +173,9 @@ npx shadcn@latest docs button dialog select
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **merge**, or **skip**?
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply --preset <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
@@ -209,6 +210,9 @@ npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (ba
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
npx shadcn@latest apply a2r6bw
|
||||
npx shadcn@latest apply --preset a2r6bw --only theme
|
||||
npx shadcn@latest apply --preset a2r6bw --only font
|
||||
npx shadcn@latest apply --preset a2r6bw --only theme,font
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
|
||||
Reference in New Issue
Block a user