Files
shadcn-ui/packages/shadcn/test/utils/get-config.test.ts
shadcn eb42ae25fd feat: add support for package imports (#10519)
* feat: add support for package imports

* fix

* test(cli): surface add command failures

* test(cli): remove stale pnpm pin from fixture

* fix(cli): reject invalid package import targets

* fix(cli): address package import review feedback

* test: expand coverage

* docs: add package imports docs
2026-05-05 12:24:21 +04:00

780 lines
20 KiB
TypeScript

import os from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import {
createConfig,
getBase,
getConfig,
getRawConfig,
getWorkspaceConfig,
} from "../../src/utils/get-config"
import { getProjectConfig } from "../../src/utils/get-project-info"
test("get raw config", async () => {
expect(
await getRawConfig(path.resolve(__dirname, "../fixtures/config-none"))
).toEqual(null)
expect(
await getRawConfig(path.resolve(__dirname, "../fixtures/config-partial"))
).toEqual({
style: "default",
tailwind: {
config: "./tailwind.config.ts",
css: "./src/assets/css/tailwind.css",
baseColor: "neutral",
cssVariables: false,
},
rsc: false,
tsx: true,
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
})
await expect(
getRawConfig(path.resolve(__dirname, "../fixtures/config-invalid"))
).rejects.toThrowError()
})
test("get project config from package imports", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/next-app-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/styles.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.ts"),
tailwindCss: path.resolve(cwd, "src/app/styles.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from generic package import prefix", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-app-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#custom/components",
ui: "#custom/components/ui",
lib: "#custom/lib",
hooks: "#custom/hooks",
utils: "#custom/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from root package imports", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-root-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from partial package imports", async () => {
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-partial-imports"
)
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get config", async () => {
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-none"))
).toEqual(null)
await expect(
getConfig(path.resolve(__dirname, "../fixtures/config-invalid"))
).rejects.toThrowError()
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-partial"))
).toEqual({
style: "default",
tailwind: {
config: "./tailwind.config.ts",
css: "./src/assets/css/tailwind.css",
baseColor: "neutral",
cssVariables: false,
},
rsc: false,
tsx: true,
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-partial"),
tailwindConfig: path.resolve(
__dirname,
"../fixtures/config-partial",
"tailwind.config.ts"
),
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-partial",
"./src/assets/css/tailwind.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-partial",
"./components"
),
utils: path.resolve(
__dirname,
"../fixtures/config-partial",
"./lib/utils"
),
ui: path.resolve(
__dirname,
"../fixtures/config-partial",
"./components/ui"
),
hooks: path.resolve(__dirname, "../fixtures/config-partial", "./hooks"),
lib: path.resolve(__dirname, "../fixtures/config-partial", "./lib"),
},
iconLibrary: "lucide",
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-full"))
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/globals.css",
cssVariables: true,
prefix: "tw-",
},
aliases: {
components: "~/components",
utils: "~/lib/utils",
lib: "~/lib",
hooks: "~/lib/hooks",
ui: "~/ui",
},
iconLibrary: "lucide",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-full"),
tailwindConfig: path.resolve(
__dirname,
"../fixtures/config-full",
"tailwind.config.ts"
),
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-full",
"./src/app/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-full",
"./src/components"
),
ui: path.resolve(__dirname, "../fixtures/config-full", "./src/ui"),
hooks: path.resolve(
__dirname,
"../fixtures/config-full",
"./src/lib/hooks"
),
lib: path.resolve(__dirname, "../fixtures/config-full", "./src/lib"),
utils: path.resolve(
__dirname,
"../fixtures/config-full",
"./src/lib/utils"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-jsx"))
).toEqual({
style: "default",
tailwind: {
config: "./tailwind.config.js",
css: "./src/assets/css/tailwind.css",
baseColor: "neutral",
cssVariables: false,
},
rsc: false,
tsx: false,
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-jsx"),
tailwindConfig: path.resolve(
__dirname,
"../fixtures/config-jsx",
"tailwind.config.js"
),
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-jsx",
"./src/assets/css/tailwind.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-jsx",
"./components"
),
ui: path.resolve(__dirname, "../fixtures/config-jsx", "./components/ui"),
utils: path.resolve(__dirname, "../fixtures/config-jsx", "./lib/utils"),
hooks: path.resolve(__dirname, "../fixtures/config-jsx", "./hooks"),
lib: path.resolve(__dirname, "../fixtures/config-jsx", "./lib"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-imports"))
).toEqual({
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/globals.css",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-imports"),
tailwindConfig: path.resolve(
__dirname,
"../fixtures/config-imports",
"tailwind.config.ts"
),
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/app/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/components/ui"
),
lib: path.resolve(__dirname, "../fixtures/config-imports", "src/lib"),
hooks: path.resolve(__dirname, "../fixtures/config-imports", "src/hooks"),
utils: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(
path.resolve(__dirname, "../fixtures/config-imports-extensions")
)
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-imports-extensions"),
tailwindConfig: "",
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/index.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/components/ui"
),
lib: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/lib"
),
hooks: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/hooks"
),
utils: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(
path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
)
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "../../packages/ui/src/styles/globals.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "@workspace/ui/components",
lib: "#lib",
hooks: "#hooks",
utils: "@workspace/ui/lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
),
tailwindConfig: "",
tailwindCss: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/components"
),
lib: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/lib"
),
hooks: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/hooks"
),
utils: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get workspace config resolves cross-package aliases without tsconfig paths", async () => {
const appCwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
const uiCwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui"
)
const config = await getConfig(appCwd)
if (!config) {
throw new Error("Failed to load monorepo app config")
}
expect(await getWorkspaceConfig(config)).toMatchObject({
components: {
resolvedPaths: {
cwd: appCwd,
},
},
ui: {
resolvedPaths: {
cwd: uiCwd,
},
},
lib: {
resolvedPaths: {
cwd: appCwd,
},
},
hooks: {
resolvedPaths: {
cwd: appCwd,
},
},
})
})
test("get workspace config shows an actionable error when a workspace package is missing imports", async () => {
const fixtureRoot = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "shadcn-workspace-config-")
)
try {
await fs.copy(fixtureRoot, tempDir)
const uiPackageJsonPath = path.resolve(tempDir, "packages/ui/package.json")
const uiPackageJson = await fs.readJson(uiPackageJsonPath)
delete uiPackageJson.imports
await fs.writeJson(uiPackageJsonPath, uiPackageJson, { spaces: 2 })
const config = await getConfig(path.resolve(tempDir, "apps/web"))
if (!config) {
throw new Error("Failed to load broken monorepo app config")
}
await expect(getWorkspaceConfig(config)).rejects.toThrowError(
new RegExp(
"Could not resolve the following aliases.*packages/ui.*components, ui, lib, hooks, utils",
"s"
)
)
} finally {
await fs.remove(tempDir)
}
})
test("get workspace config shows an actionable error when a workspace package is missing components.json", async () => {
const fixtureRoot = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "shadcn-workspace-config-")
)
try {
await fs.copy(fixtureRoot, tempDir)
await fs.remove(path.resolve(tempDir, "packages/ui/components.json"))
const config = await getConfig(path.resolve(tempDir, "apps/web"))
if (!config) {
throw new Error("Failed to load broken monorepo app config")
}
await expect(getWorkspaceConfig(config)).rejects.toThrowError(
new RegExp(
"Could not load the workspace config.*packages/ui.*components.json.*path aliases or package imports",
"s"
)
)
} finally {
await fs.remove(tempDir)
}
})
describe("getBase", () => {
test("returns radix for radix styles", () => {
expect(getBase("radix-nova")).toBe("radix")
expect(getBase("radix-vega")).toBe("radix")
})
test("returns base for base styles", () => {
expect(getBase("base-nova")).toBe("base")
expect(getBase("base-vega")).toBe("base")
})
test("returns radix for undefined", () => {
expect(getBase(undefined)).toBe("radix")
})
})
describe("createConfig", () => {
test("creates default config when called without arguments", () => {
const config = createConfig()
expect(config).toMatchObject({
resolvedPaths: {
cwd: expect.any(String),
tailwindConfig: "",
tailwindCss: "",
utils: "",
components: "",
ui: "",
lib: "",
hooks: "",
},
style: "",
tailwind: {
config: "",
css: "",
baseColor: "",
cssVariables: false,
},
rsc: false,
tsx: true,
aliases: {
components: "",
utils: "",
},
})
})
test("overrides cwd in resolvedPaths", () => {
const customCwd = "/custom/path"
const config = createConfig({
resolvedPaths: {
cwd: customCwd,
},
})
expect(config.resolvedPaths.cwd).toBe(customCwd)
expect(config.resolvedPaths.components).toBe("")
expect(config.resolvedPaths.utils).toBe("")
})
test("overrides style", () => {
const config = createConfig({
style: "new-york",
})
expect(config.style).toBe("new-york")
})
test("overrides tailwind settings", () => {
const config = createConfig({
tailwind: {
baseColor: "slate",
cssVariables: true,
},
})
expect(config.tailwind.baseColor).toBe("slate")
expect(config.tailwind.cssVariables).toBe(true)
expect(config.tailwind.config).toBe("")
expect(config.tailwind.css).toBe("")
})
test("overrides boolean flags", () => {
const config = createConfig({
rsc: true,
tsx: false,
})
expect(config.rsc).toBe(true)
expect(config.tsx).toBe(false)
})
test("overrides aliases", () => {
const config = createConfig({
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
})
expect(config.aliases.components).toBe("@/components")
expect(config.aliases.utils).toBe("@/lib/utils")
})
test("handles complex partial overrides", () => {
const config = createConfig({
style: "default",
resolvedPaths: {
cwd: "/my/project",
components: "/my/project/src/components",
},
tailwind: {
baseColor: "zinc",
prefix: "tw-",
},
aliases: {
ui: "@/components/ui",
},
})
expect(config.style).toBe("default")
expect(config.resolvedPaths.cwd).toBe("/my/project")
expect(config.resolvedPaths.components).toBe("/my/project/src/components")
expect(config.resolvedPaths.utils).toBe("")
expect(config.tailwind.baseColor).toBe("zinc")
expect(config.tailwind.prefix).toBe("tw-")
expect(config.tailwind.css).toBe("")
expect(config.aliases.ui).toBe("@/components/ui")
expect(config.aliases.components).toBe("")
})
test("returns new object instances", () => {
const config1 = createConfig()
const config2 = createConfig()
expect(config1).not.toBe(config2)
expect(config1.resolvedPaths).not.toBe(config2.resolvedPaths)
expect(config1.tailwind).not.toBe(config2.tailwind)
expect(config1.aliases).not.toBe(config2.aliases)
})
})