mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat: add preset commands (#10530)
* feat(cli): add preset commands * docs(skill): update preset command guidance * docs(cli): document preset commands * chore: changeset * fix(cli): refine preset command output * fix(cli): align preset decode output * fix(cli): update preset output fields * docs(changelog): add preset commands entry * docs(changelog): show preset command output * docs(changelog): clarify preset resolve examples * docs(changelog): refine preset examples * docs(changelog): add preset command sections * docs(changelog): show preset resolve output * docs(changelog): clarify preset open example * docs(changelog): update preset resolve example * docs: update preset announcement * docs: link preset announcement to changelog * test: increase next init timeout
This commit is contained in:
@@ -191,58 +191,7 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
|
||||
})
|
||||
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Preset"))
|
||||
if (!data.preset?.code) {
|
||||
printEntries({
|
||||
"--preset": "-",
|
||||
})
|
||||
} else {
|
||||
const fallbacks = data.preset.fallbacks ?? []
|
||||
const formatPresetValue = (key: string, value: string | undefined) => {
|
||||
const suffix = fallbacks.includes(key) ? "*" : ""
|
||||
return `${value ?? "-"}${suffix}`
|
||||
}
|
||||
|
||||
printEntries({
|
||||
"--preset": data.preset.code,
|
||||
url: `${SHADCN_URL}/create?preset=${data.preset.code}`,
|
||||
style: data.preset.values?.style ?? "-",
|
||||
baseColor: formatPresetValue(
|
||||
"baseColor",
|
||||
data.preset.values?.baseColor
|
||||
),
|
||||
theme: formatPresetValue("theme", data.preset.values?.theme),
|
||||
chartColor: formatPresetValue(
|
||||
"chartColor",
|
||||
data.preset.values?.chartColor
|
||||
),
|
||||
iconLibrary: formatPresetValue(
|
||||
"iconLibrary",
|
||||
data.preset.values?.iconLibrary
|
||||
),
|
||||
font: formatPresetValue("font", data.preset.values?.font),
|
||||
fontHeading: formatPresetValue(
|
||||
"fontHeading",
|
||||
data.preset.values?.fontHeading
|
||||
),
|
||||
radius: formatPresetValue("radius", data.preset.values?.radius),
|
||||
menuAccent: formatPresetValue(
|
||||
"menuAccent",
|
||||
data.preset.values?.menuAccent
|
||||
),
|
||||
menuColor: formatPresetValue(
|
||||
"menuColor",
|
||||
data.preset.values?.menuColor
|
||||
),
|
||||
})
|
||||
|
||||
if (fallbacks.length > 0) {
|
||||
logger.log("")
|
||||
logger.log(
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
}
|
||||
}
|
||||
printPresetInfo(data.preset)
|
||||
|
||||
// Aliases.
|
||||
logger.break()
|
||||
@@ -296,8 +245,54 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
|
||||
logger.break()
|
||||
}
|
||||
|
||||
export function printPresetInfo(
|
||||
preset: Awaited<ReturnType<typeof collectInfo>>["preset"],
|
||||
options: {
|
||||
fallbackNote?: string
|
||||
} = {}
|
||||
) {
|
||||
logger.log(highlighter.info("Preset"))
|
||||
if (!preset?.code) {
|
||||
printEntries({
|
||||
code: "-",
|
||||
})
|
||||
} else {
|
||||
const fallbacks = preset.fallbacks ?? []
|
||||
const formatPresetValue = (key: string, value: string | undefined) => {
|
||||
const suffix = fallbacks.includes(key) ? "*" : ""
|
||||
return `${value ?? "-"}${suffix}`
|
||||
}
|
||||
|
||||
printEntries({
|
||||
code: preset.code,
|
||||
version: preset.code[0],
|
||||
style: preset.values?.style ?? "-",
|
||||
baseColor: formatPresetValue("baseColor", preset.values?.baseColor),
|
||||
theme: formatPresetValue("theme", preset.values?.theme),
|
||||
chartColor: formatPresetValue("chartColor", preset.values?.chartColor),
|
||||
iconLibrary: formatPresetValue("iconLibrary", preset.values?.iconLibrary),
|
||||
font: formatPresetValue("font", preset.values?.font),
|
||||
fontHeading: formatPresetValue("fontHeading", preset.values?.fontHeading),
|
||||
radius: formatPresetValue("radius", preset.values?.radius),
|
||||
menuAccent: formatPresetValue("menuAccent", preset.values?.menuAccent),
|
||||
menuColor: formatPresetValue("menuColor", preset.values?.menuColor),
|
||||
url: `${SHADCN_URL}/create?preset=${preset.code}`,
|
||||
})
|
||||
|
||||
if (fallbacks.length > 0) {
|
||||
logger.log("")
|
||||
logger.log(
|
||||
options.fallbackNote ??
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printEntries(entries: Record<string, string>) {
|
||||
const maxKeyLength = Math.max(...Object.keys(entries).map((k) => k.length))
|
||||
const maxKeyLength = Math.max(
|
||||
...Object.keys(entries).map((key) => key.length)
|
||||
)
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
logger.log(` ${key.padEnd(maxKeyLength + 2)}${value}`)
|
||||
}
|
||||
|
||||
492
packages/shadcn/src/commands/preset.test.ts
Normal file
492
packages/shadcn/src/commands/preset.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { resolveProjectPreset } from "@/src/preset/resolve"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import open from "open"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
decodePresetCode,
|
||||
getPresetUrl,
|
||||
openCommand,
|
||||
preset as presetCommand,
|
||||
printPresetDecode,
|
||||
resolve as resolveCommand,
|
||||
url,
|
||||
} from "./preset"
|
||||
|
||||
vi.mock("fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("fs")>()
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("open", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/preset/resolve", () => ({
|
||||
resolveProjectPreset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-config", () => ({
|
||||
getBase: vi.fn(() => "radix"),
|
||||
getConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-monorepo-info", () => ({
|
||||
formatMonorepoMessage: vi.fn(),
|
||||
getMonorepoTargets: vi.fn(),
|
||||
isMonorepoRoot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-project-info", () => ({
|
||||
getProjectComponents: vi.fn(),
|
||||
getProjectInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn((error) => {
|
||||
throw error
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/highlighter", () => ({
|
||||
highlighter: {
|
||||
info: (value: string) => value,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
break: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("preset commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(existsSync).mockReturnValue(true)
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(false)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue([])
|
||||
vi.mocked(getConfig).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof getConfig>>
|
||||
)
|
||||
vi.mocked(getProjectInfo).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof getProjectInfo>>
|
||||
)
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValue({
|
||||
code: "b123",
|
||||
fallbacks: [],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "blue",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("decodes current preset codes", () => {
|
||||
const result = decodePresetCode("b0")
|
||||
|
||||
expect(result).toEqual({
|
||||
code: "b0",
|
||||
version: "b",
|
||||
values: {
|
||||
style: "nova",
|
||||
baseColor: "neutral",
|
||||
theme: "neutral",
|
||||
chartColor: "neutral",
|
||||
iconLibrary: "lucide",
|
||||
font: "inter",
|
||||
fontHeading: "inherit",
|
||||
radius: "default",
|
||||
menuAccent: "subtle",
|
||||
menuColor: "default",
|
||||
},
|
||||
derived: [],
|
||||
url: getPresetUrl("b0"),
|
||||
})
|
||||
})
|
||||
|
||||
it("applies compatibility values for older preset codes", () => {
|
||||
const result = decodePresetCode("a0")
|
||||
|
||||
expect(result.version).toBe("a")
|
||||
expect(result.values.chartColor).toBe("blue")
|
||||
expect(result.derived).toEqual(["chartColor"])
|
||||
})
|
||||
|
||||
it("prints compatibility markers in human output", () => {
|
||||
printPresetDecode(decodePresetCode("a0"))
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("Preset")
|
||||
expect(logger.log).toHaveBeenCalledWith(" code a0")
|
||||
expect(logger.log).toHaveBeenCalledWith(" version a")
|
||||
expect(logger.log).toHaveBeenCalledWith(" chartColor blue*")
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` url ${getPresetUrl("a0")}`
|
||||
)
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
" * Compatibility value for older preset versions."
|
||||
)
|
||||
})
|
||||
|
||||
it("matches decoded preset output ordering to resolved preset output", () => {
|
||||
printPresetDecode(decodePresetCode("b0"))
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("Preset")
|
||||
expect(vi.mocked(logger.log).mock.calls.map((call) => call[0])).toEqual([
|
||||
"Preset",
|
||||
" code b0",
|
||||
" version b",
|
||||
" style nova",
|
||||
" baseColor neutral",
|
||||
" theme neutral",
|
||||
" chartColor neutral",
|
||||
" iconLibrary lucide",
|
||||
" font inter",
|
||||
" fontHeading inherit",
|
||||
" radius default",
|
||||
" menuAccent subtle",
|
||||
" menuColor default",
|
||||
` url ${getPresetUrl("b0")}`,
|
||||
])
|
||||
})
|
||||
|
||||
it("prints decoded preset JSON with derived values", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await presetCommand.parseAsync(["decode", "a0", "--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
|
||||
code: "a0",
|
||||
version: "a",
|
||||
values: {
|
||||
style: "nova",
|
||||
baseColor: "neutral",
|
||||
theme: "neutral",
|
||||
chartColor: "blue",
|
||||
iconLibrary: "lucide",
|
||||
font: "inter",
|
||||
fontHeading: "inherit",
|
||||
radius: "default",
|
||||
menuAccent: "subtle",
|
||||
menuColor: "default",
|
||||
},
|
||||
derived: ["chartColor"],
|
||||
url: getPresetUrl("a0"),
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("rejects invalid codes", () => {
|
||||
expect(() => decodePresetCode("c0")).toThrow("Invalid preset code: c0")
|
||||
})
|
||||
|
||||
it("rejects URLs", () => {
|
||||
expect(() =>
|
||||
decodePresetCode("https://ui.shadcn.com/create?preset=a0")
|
||||
).toThrow("Invalid preset code: https://ui.shadcn.com/create?preset=a0")
|
||||
})
|
||||
|
||||
it("prints the create URL from preset url", async () => {
|
||||
await url.parseAsync(["a0"], { from: "user" })
|
||||
|
||||
expect(logger.break).not.toHaveBeenCalled()
|
||||
expect(logger.log).toHaveBeenCalledWith(getPresetUrl("a0"))
|
||||
})
|
||||
|
||||
it("prints the create URL before opening it", async () => {
|
||||
vi.mocked(open).mockImplementation(async () => {
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` Opening ${getPresetUrl("a0")} in your browser.`
|
||||
)
|
||||
return {} as Awaited<ReturnType<typeof open>>
|
||||
})
|
||||
|
||||
await openCommand.parseAsync(["a0"], { from: "user" })
|
||||
|
||||
expect(logger.break).toHaveBeenCalledTimes(2)
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` Opening ${getPresetUrl("a0")} in your browser.`
|
||||
)
|
||||
expect(open).toHaveBeenCalledWith(getPresetUrl("a0"))
|
||||
})
|
||||
|
||||
it("does not open invalid preset codes", async () => {
|
||||
const exit = mockProcessExit()
|
||||
|
||||
await expect(
|
||||
openCommand.parseAsync(["https://ui.shadcn.com/create?preset=a0"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Invalid preset code: https://ui.shadcn.com/create?preset=a0"
|
||||
)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("fails when opening the browser fails", async () => {
|
||||
const exit = mockProcessExit()
|
||||
vi.mocked(open).mockRejectedValue(new Error("open failed"))
|
||||
|
||||
await expect(
|
||||
openCommand.parseAsync(["a0"], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
` Opening ${getPresetUrl("a0")} in your browser.`
|
||||
)
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Failed to open preset URL: open failed"
|
||||
)
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("resolves a project preset using the existing resolver", async () => {
|
||||
await resolveCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("Preset")
|
||||
expect(logger.log).toHaveBeenCalledWith(" code b123")
|
||||
expect(logger.log).toHaveBeenCalledWith(" version b")
|
||||
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("b123"))
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://ui.shadcn.com/create?preset=b123")
|
||||
)
|
||||
expect(resolveProjectPreset).toHaveBeenCalledWith({}, {})
|
||||
})
|
||||
|
||||
it("prints fallback markers when resolving a project preset", async () => {
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
|
||||
code: "b123",
|
||||
fallbacks: ["theme"],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "neutral",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
|
||||
await resolveCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("neutral*"))
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
})
|
||||
|
||||
it("prints the resolved preset as JSON", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
|
||||
code: "b123",
|
||||
fallbacks: [],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "blue",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints fallback metadata in resolved preset JSON", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
|
||||
code: "b123",
|
||||
fallbacks: ["theme"],
|
||||
values: {
|
||||
style: "luma",
|
||||
baseColor: "mist",
|
||||
theme: "neutral",
|
||||
chartColor: "emerald",
|
||||
iconLibrary: "phosphor",
|
||||
font: "inter",
|
||||
fontHeading: "lora",
|
||||
radius: "large",
|
||||
menuAccent: "bold",
|
||||
menuColor: "inverted",
|
||||
},
|
||||
})
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
|
||||
fallbacks: ["theme"],
|
||||
values: {
|
||||
theme: "neutral",
|
||||
},
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("supports preset info as an alias for preset resolve", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
await presetCommand.parseAsync(["info", "--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
|
||||
code: "b123",
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("resolves presets from the provided cwd", async () => {
|
||||
await resolveCommand.parseAsync(["--cwd", "apps/web"], { from: "user" })
|
||||
|
||||
expect(getConfig).toHaveBeenCalledWith(path.resolve("apps/web"))
|
||||
expect(getProjectInfo).toHaveBeenCalledWith(path.resolve("apps/web"))
|
||||
})
|
||||
|
||||
it("prints no-config output when no components.json exists", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(getConfig).mockResolvedValueOnce(null)
|
||||
|
||||
await resolveCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(logger.log).toHaveBeenCalledWith("No components.json found.")
|
||||
})
|
||||
|
||||
it("prints null JSON when no components.json exists", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(getConfig).mockResolvedValueOnce(null)
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints null JSON when no preset can be resolved", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
|
||||
code: null,
|
||||
fallbacks: [],
|
||||
values: null,
|
||||
})
|
||||
|
||||
await resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("matches info monorepo-root behavior", async () => {
|
||||
const exit = mockProcessExit()
|
||||
const targets = [{ name: "apps/web" }]
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
|
||||
|
||||
await expect(
|
||||
resolveCommand.parseAsync([], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(formatMonorepoMessage).toHaveBeenCalledWith(
|
||||
"preset resolve",
|
||||
targets
|
||||
)
|
||||
expect(getConfig).not.toHaveBeenCalled()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("prints monorepo-root JSON for preset resolve --json", async () => {
|
||||
const exit = mockProcessExit()
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const targets = [{ name: "apps/web" }]
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
|
||||
|
||||
await expect(
|
||||
resolveCommand.parseAsync(["--json"], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
|
||||
error: "monorepo_root",
|
||||
message:
|
||||
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
|
||||
targets: ["apps/web"],
|
||||
})
|
||||
expect(formatMonorepoMessage).not.toHaveBeenCalled()
|
||||
expect(getConfig).not.toHaveBeenCalled()
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("prints help when no preset subcommand is provided", async () => {
|
||||
const outputHelp = vi
|
||||
.spyOn(presetCommand, "outputHelp")
|
||||
.mockImplementation(() => {})
|
||||
|
||||
await presetCommand.parseAsync([], { from: "user" })
|
||||
|
||||
expect(outputHelp).toHaveBeenCalledOnce()
|
||||
outputHelp.mockRestore()
|
||||
})
|
||||
|
||||
it("errors on unknown preset subcommands", async () => {
|
||||
const exit = mockProcessExit()
|
||||
const outputHelp = vi
|
||||
.spyOn(presetCommand, "outputHelp")
|
||||
.mockImplementation(() => {})
|
||||
const writeErr = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true)
|
||||
|
||||
await expect(
|
||||
presetCommand.parseAsync(["bogus"], { from: "user" })
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(outputHelp).not.toHaveBeenCalled()
|
||||
expect(writeErr.mock.calls.join("")).toContain("too many arguments")
|
||||
writeErr.mockRestore()
|
||||
outputHelp.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
function mockProcessExit() {
|
||||
return vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit:${code}`)
|
||||
})
|
||||
}
|
||||
231
packages/shadcn/src/commands/preset.ts
Normal file
231
packages/shadcn/src/commands/preset.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { printPresetInfo } from "@/src/commands/info"
|
||||
import {
|
||||
decodePreset,
|
||||
V1_CHART_COLOR_MAP,
|
||||
type PresetConfig,
|
||||
} from "@/src/preset/preset"
|
||||
import { resolveProjectPreset } from "@/src/preset/resolve"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
import open from "open"
|
||||
|
||||
type PresetValues = Omit<PresetConfig, "chartColor"> & {
|
||||
chartColor: NonNullable<PresetConfig["chartColor"]>
|
||||
}
|
||||
|
||||
type PresetDecodeResult = {
|
||||
code: string
|
||||
version: string
|
||||
values: PresetValues
|
||||
derived: string[]
|
||||
url: string
|
||||
}
|
||||
|
||||
export function getPresetUrl(code: string) {
|
||||
return `${SHADCN_URL}/create?preset=${code}`
|
||||
}
|
||||
|
||||
export function decodePresetCode(code: string): PresetDecodeResult {
|
||||
const decoded = decodePreset(code)
|
||||
|
||||
if (!decoded) {
|
||||
throw new Error(`Invalid preset code: ${code}`)
|
||||
}
|
||||
|
||||
const derived: string[] = []
|
||||
const chartColor = (decoded.chartColor ??
|
||||
V1_CHART_COLOR_MAP[decoded.theme] ??
|
||||
decoded.theme) as PresetValues["chartColor"]
|
||||
|
||||
if (!decoded.chartColor) {
|
||||
derived.push("chartColor")
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
version: code[0],
|
||||
values: {
|
||||
...decoded,
|
||||
chartColor,
|
||||
},
|
||||
derived,
|
||||
url: getPresetUrl(code),
|
||||
}
|
||||
}
|
||||
|
||||
export function printPresetDecode(result: PresetDecodeResult) {
|
||||
printPresetInfo(
|
||||
{
|
||||
code: result.code,
|
||||
fallbacks: result.derived,
|
||||
values: result.values,
|
||||
},
|
||||
{
|
||||
fallbackNote: " * Compatibility value for older preset versions.",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const decode = new Command()
|
||||
.name("decode")
|
||||
.description("decode a preset code")
|
||||
.argument("<code>", "the preset code to decode")
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action((code, opts) => {
|
||||
try {
|
||||
const result = decodePresetCode(code)
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
code: result.code,
|
||||
version: result.version,
|
||||
values: result.values,
|
||||
derived: result.derived,
|
||||
url: result.url,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
printPresetDecode(result)
|
||||
} catch (error) {
|
||||
handlePresetError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const url = new Command()
|
||||
.name("url")
|
||||
.description("get the create URL for a preset code")
|
||||
.argument("<code>", "the preset code")
|
||||
.action((code) => {
|
||||
try {
|
||||
logger.log(decodePresetCode(code).url)
|
||||
} catch (error) {
|
||||
handlePresetError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const openCommand = new Command()
|
||||
.name("open")
|
||||
.description("open a preset code in the browser")
|
||||
.argument("<code>", "the preset code")
|
||||
.action(async (code) => {
|
||||
let presetUrl: string
|
||||
|
||||
try {
|
||||
presetUrl = decodePresetCode(code).url
|
||||
} catch (error) {
|
||||
handlePresetError(error)
|
||||
return
|
||||
}
|
||||
|
||||
logger.break()
|
||||
logger.log(` Opening ${presetUrl} in your browser.`)
|
||||
logger.break()
|
||||
|
||||
try {
|
||||
await open(presetUrl)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
handlePresetError(new Error(`Failed to open preset URL: ${message}`))
|
||||
}
|
||||
})
|
||||
|
||||
export const resolve = new Command()
|
||||
.name("resolve")
|
||||
.alias("info")
|
||||
.description("resolve a preset from your project")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const cwd = path.resolve(opts.cwd)
|
||||
|
||||
if (
|
||||
!existsSync(path.resolve(cwd, "components.json")) &&
|
||||
(await isMonorepoRoot(cwd))
|
||||
) {
|
||||
const targets = await getMonorepoTargets(cwd)
|
||||
if (targets.length > 0) {
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: "monorepo_root",
|
||||
message:
|
||||
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
|
||||
targets: targets.map((target) => target.name),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
formatMonorepoMessage("preset resolve", targets)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const config = await getConfig(cwd)
|
||||
if (!config) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(null, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("No components.json found.")
|
||||
return
|
||||
}
|
||||
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
const preset = await resolveProjectPreset(config, projectInfo)
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(preset.code ? preset : null, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
printPresetInfo(preset)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const preset = new Command()
|
||||
.name("preset")
|
||||
.description("manage presets")
|
||||
.addCommand(decode)
|
||||
.addCommand(resolve)
|
||||
.addCommand(url)
|
||||
.addCommand(openCommand)
|
||||
.action(() => {
|
||||
preset.outputHelp()
|
||||
})
|
||||
|
||||
function handlePresetError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { info } from "@/src/commands/info"
|
||||
import { init } from "@/src/commands/init"
|
||||
import { mcp } from "@/src/commands/mcp"
|
||||
import { migrate } from "@/src/commands/migrate"
|
||||
import { preset } from "@/src/commands/preset"
|
||||
import { registry } from "@/src/commands/registry"
|
||||
import { search } from "@/src/commands/search"
|
||||
import { view } from "@/src/commands/view"
|
||||
@@ -40,6 +41,7 @@ async function main() {
|
||||
.addCommand(info)
|
||||
.addCommand(build)
|
||||
.addCommand(mcp)
|
||||
.addCommand(preset)
|
||||
.addCommand(registry)
|
||||
|
||||
program.parse()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createRegistryServer } from "../utils/registry"
|
||||
describe("shadcn init - next-app", () => {
|
||||
it("should init with default configuration", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults"])
|
||||
await npxShadcn(fixturePath, ["init", "--defaults"], { timeout: 120000 })
|
||||
|
||||
const componentsJsonPath = path.join(fixturePath, "components.json")
|
||||
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
|
||||
@@ -54,7 +54,9 @@ describe("shadcn init - next-app", () => {
|
||||
|
||||
it("should init without CSS variables", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"])
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"], {
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
const componentsJson = await fs.readJson(
|
||||
path.join(fixturePath, "components.json")
|
||||
@@ -64,7 +66,9 @@ describe("shadcn init - next-app", () => {
|
||||
|
||||
it("should init with components", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "button"])
|
||||
await npxShadcn(fixturePath, ["init", "--defaults", "button"], {
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
expect(
|
||||
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
|
||||
|
||||
Reference in New Issue
Block a user