feat(shadcn): add support for universal registry item (#7782)

* feat(shadcn): add support for universal registry item

* chore: changeset
This commit is contained in:
shadcn
2025-07-10 20:17:45 +04:00
committed by GitHub
parent 6407a3b330
commit 06d03d64f4
7 changed files with 386 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add universal registry items support

View File

@@ -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.

View File

@@ -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)
})
})

View File

@@ -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)
)
}

View File

@@ -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
}

View File

@@ -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[] = []

View File

@@ -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)
})
})