mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat: add apply --only
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user