mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-02-09 02:49:29 +08:00
feat(shadcn): add support for universal registry item (#7782)
* feat(shadcn): add support for universal registry item * chore: changeset
This commit is contained in:
5
.changeset/two-jobs-swim.md
Normal file
5
.changeset/two-jobs-swim.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add universal registry items support
|
||||
@@ -1,13 +1,18 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { preFlightAdd } from "@/src/preflights/preflight-add"
|
||||
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
|
||||
import { registryItemTypeSchema } from "@/src/registry/schema"
|
||||
import { isLocalFile, isUrl } from "@/src/registry/utils"
|
||||
import {
|
||||
isLocalFile,
|
||||
isUniversalRegistryItem,
|
||||
isUrl,
|
||||
} from "@/src/registry/utils"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { createProject } from "@/src/utils/create-project"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
@@ -78,13 +83,14 @@ export const add = new Command()
|
||||
})
|
||||
|
||||
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
|
||||
let registryItem: any = null
|
||||
|
||||
if (
|
||||
components.length > 0 &&
|
||||
(isUrl(components[0]) || isLocalFile(components[0]))
|
||||
) {
|
||||
const item = await getRegistryItem(components[0], "")
|
||||
itemType = item?.type
|
||||
registryItem = await getRegistryItem(components[0], "")
|
||||
itemType = registryItem?.type
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -130,6 +136,22 @@ export const add = new Command()
|
||||
}
|
||||
}
|
||||
|
||||
if (isUniversalRegistryItem(registryItem)) {
|
||||
// Universal items only cares about the cwd.
|
||||
if (!fs.existsSync(options.cwd)) {
|
||||
throw new Error(`Directory ${options.cwd} does not exist`)
|
||||
}
|
||||
|
||||
const minimalConfig = createConfig({
|
||||
resolvedPaths: {
|
||||
cwd: options.cwd,
|
||||
},
|
||||
})
|
||||
|
||||
await addComponents(options.components, minimalConfig, options)
|
||||
return
|
||||
}
|
||||
|
||||
let { errors, config } = await preFlightAdd(options)
|
||||
|
||||
// No components.json file. Prompt the user to run init.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getDependencyFromModuleSpecifier, isLocalFile, isUrl } from "./utils"
|
||||
import {
|
||||
getDependencyFromModuleSpecifier,
|
||||
isLocalFile,
|
||||
isUniversalRegistryItem,
|
||||
isUrl,
|
||||
} from "./utils"
|
||||
|
||||
describe("getDependencyFromModuleSpecifier", () => {
|
||||
it("should return the first part of a non-scoped package with path", () => {
|
||||
@@ -130,3 +135,139 @@ describe("isLocalFile", () => {
|
||||
expect(isLocalFile("/absolute/path")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isUniversalRegistryItem", () => {
|
||||
it("should return true when all files have targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: "src/file1.ts",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{
|
||||
path: "file2.ts",
|
||||
target: "src/utils/file2.ts",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when some files lack targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: "src/file1.ts",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{ path: "file2.ts", target: "", type: "registry:lib" as const },
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when no files have targets", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{ path: "file1.ts", target: "", type: "registry:lib" as const },
|
||||
{ path: "file2.ts", target: "", type: "registry:lib" as const },
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files array is empty", () => {
|
||||
const registryItem = {
|
||||
files: [],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when files is undefined", () => {
|
||||
const registryItem = {}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when registryItem is null", () => {
|
||||
expect(isUniversalRegistryItem(null)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when registryItem is undefined", () => {
|
||||
expect(isUniversalRegistryItem(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when target is null", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "file1.ts",
|
||||
target: null as any,
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when target is undefined", () => {
|
||||
const registryItem = {
|
||||
files: [{ path: "file1.ts", type: "registry:lib" as const }],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle mixed file types correctly", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "component.tsx",
|
||||
target: "components/ui/component.tsx",
|
||||
type: "registry:ui" as const,
|
||||
},
|
||||
{
|
||||
path: "utils.ts",
|
||||
target: "lib/utils.ts",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{
|
||||
path: "hook.ts",
|
||||
target: "hooks/use-something.ts",
|
||||
type: "registry:hook" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when all targets are non-empty strings", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{ path: "file1.ts", target: " ", type: "registry:lib" as const }, // whitespace is truthy
|
||||
{ path: "file2.ts", target: "0", type: "registry:lib" as const }, // "0" is truthy
|
||||
],
|
||||
}
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle real-world example with path traversal attempts", () => {
|
||||
const registryItem = {
|
||||
files: [
|
||||
{
|
||||
path: "malicious.ts",
|
||||
target: "../../../etc/passwd",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
{
|
||||
path: "normal.ts",
|
||||
target: "src/normal.ts",
|
||||
type: "registry:lib" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
// The function should still return true - path validation is handled elsewhere
|
||||
expect(isUniversalRegistryItem(registryItem)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,3 +256,20 @@ export function isUrl(path: string) {
|
||||
export function isLocalFile(path: string) {
|
||||
return path.endsWith(".json") && !isUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a registry item is universal (framework-agnostic).
|
||||
* A universal registry item has all files with explicit targets.
|
||||
* It can be installed without framework detection or components.json.
|
||||
*/
|
||||
export function isUniversalRegistryItem(
|
||||
registryItem:
|
||||
| Pick<z.infer<typeof registryItemSchema>, "files">
|
||||
| null
|
||||
| undefined
|
||||
): boolean {
|
||||
return (
|
||||
!!registryItem?.files?.length &&
|
||||
registryItem.files.every((file) => !!file.target)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,3 +225,64 @@ export async function getTargetStyleFromConfig(cwd: string, fallback: string) {
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
return projectInfo?.tailwindVersion === "v4" ? "new-york-v4" : fallback
|
||||
}
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a config object with sensible defaults.
|
||||
* Useful for universal registry items that bypass framework detection.
|
||||
*
|
||||
* @param partial - Partial config values to override defaults
|
||||
* @returns A complete Config object
|
||||
*/
|
||||
export function createConfig(partial?: DeepPartial<Config>): Config {
|
||||
const defaultConfig: Config = {
|
||||
resolvedPaths: {
|
||||
cwd: process.cwd(),
|
||||
tailwindConfig: "",
|
||||
tailwindCss: "",
|
||||
utils: "",
|
||||
components: "",
|
||||
ui: "",
|
||||
lib: "",
|
||||
hooks: "",
|
||||
},
|
||||
style: "",
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "",
|
||||
baseColor: "",
|
||||
cssVariables: false,
|
||||
},
|
||||
rsc: false,
|
||||
tsx: true,
|
||||
aliases: {
|
||||
components: "",
|
||||
utils: "",
|
||||
},
|
||||
}
|
||||
|
||||
// Deep merge the partial config with defaults
|
||||
if (partial) {
|
||||
return {
|
||||
...defaultConfig,
|
||||
...partial,
|
||||
resolvedPaths: {
|
||||
...defaultConfig.resolvedPaths,
|
||||
...(partial.resolvedPaths || {}),
|
||||
},
|
||||
tailwind: {
|
||||
...defaultConfig.tailwind,
|
||||
...(partial.tailwind || {}),
|
||||
},
|
||||
aliases: {
|
||||
...defaultConfig.aliases,
|
||||
...(partial.aliases || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ export async function updateFiles(
|
||||
|
||||
const [projectInfo, baseColor] = await Promise.all([
|
||||
getProjectInfo(config.resolvedPaths.cwd),
|
||||
getRegistryBaseColor(config.tailwind.baseColor),
|
||||
config.tailwind.baseColor
|
||||
? getRegistryBaseColor(config.tailwind.baseColor)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
|
||||
let filesCreated: string[] = []
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import path from "path"
|
||||
import { expect, test } from "vitest"
|
||||
import { describe, expect, test } from "vitest"
|
||||
|
||||
import { getConfig, getRawConfig } from "../../src/utils/get-config"
|
||||
import {
|
||||
createConfig,
|
||||
getConfig,
|
||||
getRawConfig,
|
||||
} from "../../src/utils/get-config"
|
||||
|
||||
test("get raw config", async () => {
|
||||
expect(
|
||||
@@ -183,3 +187,129 @@ test("get config", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user