From e456fed9d3f0b7aacf7084aecc02a75e8fde622d Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 21 Apr 2026 12:57:56 +0400 Subject: [PATCH] feat: add apply --only --- .changeset/tricky-seas-shine.md | 5 + apps/v4/app/(create)/init/route.test.ts | 58 ++++++ apps/v4/app/(create)/init/route.ts | 15 +- apps/v4/registry/config.test.ts | 102 +++++++++++ apps/v4/registry/config.ts | 97 ++++++++++ packages/shadcn/src/commands/apply.test.ts | 132 +++++++++++++- packages/shadcn/src/commands/apply.ts | 195 +++++++++++++++++++-- packages/shadcn/src/preset/presets.ts | 6 +- skills/shadcn/SKILL.md | 6 +- 9 files changed, 594 insertions(+), 22 deletions(-) create mode 100644 .changeset/tricky-seas-shine.md create mode 100644 apps/v4/app/(create)/init/route.test.ts diff --git a/.changeset/tricky-seas-shine.md b/.changeset/tricky-seas-shine.md new file mode 100644 index 0000000000..a5b5dd890c --- /dev/null +++ b/.changeset/tricky-seas-shine.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add apply --only diff --git a/apps/v4/app/(create)/init/route.test.ts b/apps/v4/app/(create)/init/route.test.ts new file mode 100644 index 0000000000..49d418648c --- /dev/null +++ b/apps/v4/app/(create)/init/route.test.ts @@ -0,0 +1,58 @@ +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[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.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" + ) + }) +}) diff --git a/apps/v4/app/(create)/init/route.ts b/apps/v4/app/(create)/init/route.ts index d8d0115b7e..848b70b681 100644 --- a/apps/v4/app/(create)/init/route.ts +++ b/apps/v4/app/(create)/init/route.ts @@ -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) { diff --git a/apps/v4/registry/config.test.ts b/apps/v4/registry/config.test.ts index 76809189d7..69eefb91c1 100644 --- a/apps/v4/registry/config.test.ts +++ b/apps/v4/registry/config.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest" import { + buildPartialRegistryBase, buildRegistryBase, DEFAULT_CONFIG, designSystemConfigSchema, + parseRegistryBaseParts, } from "./config" describe("buildRegistryBase", () => { @@ -100,3 +102,103 @@ 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).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).toBeDefined() + expect(result.cssVars?.theme?.["--font-heading"]).toBe("var(--font-sans)") + }) +}) diff --git a/apps/v4/registry/config.ts b/apps/v4/registry/config.ts index dadab12a16..b230806fcd 100644 --- a/apps/v4/registry/config.ts +++ b/apps/v4/registry/config.ts @@ -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 = { + 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 = {} + + 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 +} diff --git a/packages/shadcn/src/commands/apply.test.ts b/packages/shadcn/src/commands/apply.test.ts index 58bc8c4194..d1da792b44 100644 --- a/packages/shadcn/src/commands/apply.test.ts +++ b/packages/shadcn/src/commands/apply.test.ts @@ -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 --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 --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 --only theme,font.", + ].join("\n") + ) + }) + + it("allows only with a preset", () => { + expect(() => + validateApplyOnlyPreset({ preset: "a0", only: ["theme"] }) + ).not.toThrow() + }) }) diff --git a/packages/shadcn/src/commands/apply.ts b/packages/shadcn/src/commands/apply.ts index a6e88073ba..3a3aade592 100644 --- a/packages/shadcn/src/commands/apply.ts +++ b/packages/shadcn/src/commands/apply.ts @@ -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 configuration to apply") + .option("--only [parts]", "apply only parts of a preset: theme, font") .option("-y, --yes", "skip confirmation prompt.", false) .option( "-c, --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) { 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["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 --only theme,font.", + ].join("\n") + ) + } + + return parseApplyOnlyParts(value) +} + +export function parseApplyOnlyParts(value: string) { + const aliases: Record = { + 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 --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 --only theme,font.", + ].join("\n") + ) +} + +function resolveApplyRegistryBaseConfig(options: { + registryBaseConfig: Record | undefined + existingConfig: Record + 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 = { + ...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 } ) } diff --git a/packages/shadcn/src/preset/presets.ts b/packages/shadcn/src/preset/presets.ts index a882712567..17861ce5ec 100644 --- a/packages/shadcn/src/preset/presets.ts +++ b/packages/shadcn/src/preset/presets.ts @@ -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") diff --git a/skills/shadcn/SKILL.md b/skills/shadcn/SKILL.md index 1351394fbe..2aab0b0711 100644 --- a/skills/shadcn/SKILL.md +++ b/skills/shadcn/SKILL.md @@ -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 `. Overwrites detected components, fonts, and CSS variables. + - **Partial**: `npx shadcn@latest apply --preset --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 --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 --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 ` 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