Skip to content

Commit 06d03d6

Browse files
authored
feat(shadcn): add support for universal registry item (#7782)
* feat(shadcn): add support for universal registry item * chore: changeset
1 parent 6407a3b commit 06d03d6

7 files changed

Lines changed: 386 additions & 8 deletions

File tree

.changeset/two-jobs-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shadcn": minor
3+
---
4+
5+
add universal registry items support

packages/shadcn/src/commands/add.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import fs from "fs"
12
import path from "path"
23
import { runInit } from "@/src/commands/init"
34
import { preFlightAdd } from "@/src/preflights/preflight-add"
45
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
56
import { registryItemTypeSchema } from "@/src/registry/schema"
6-
import { isLocalFile, isUrl } from "@/src/registry/utils"
7+
import {
8+
isLocalFile,
9+
isUniversalRegistryItem,
10+
isUrl,
11+
} from "@/src/registry/utils"
712
import { addComponents } from "@/src/utils/add-components"
813
import { createProject } from "@/src/utils/create-project"
914
import * as ERRORS from "@/src/utils/errors"
10-
import { getConfig } from "@/src/utils/get-config"
15+
import { createConfig, getConfig } from "@/src/utils/get-config"
1116
import { getProjectInfo } from "@/src/utils/get-project-info"
1217
import { handleError } from "@/src/utils/handle-error"
1318
import { highlighter } from "@/src/utils/highlighter"
@@ -78,13 +83,14 @@ export const add = new Command()
7883
})
7984

8085
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
86+
let registryItem: any = null
8187

8288
if (
8389
components.length > 0 &&
8490
(isUrl(components[0]) || isLocalFile(components[0]))
8591
) {
86-
const item = await getRegistryItem(components[0], "")
87-
itemType = item?.type
92+
registryItem = await getRegistryItem(components[0], "")
93+
itemType = registryItem?.type
8894
}
8995

9096
if (
@@ -130,6 +136,22 @@ export const add = new Command()
130136
}
131137
}
132138

139+
if (isUniversalRegistryItem(registryItem)) {
140+
// Universal items only cares about the cwd.
141+
if (!fs.existsSync(options.cwd)) {
142+
throw new Error(`Directory ${options.cwd} does not exist`)
143+
}
144+
145+
const minimalConfig = createConfig({
146+
resolvedPaths: {
147+
cwd: options.cwd,
148+
},
149+
})
150+
151+
await addComponents(options.components, minimalConfig, options)
152+
return
153+
}
154+
133155
let { errors, config } = await preFlightAdd(options)
134156

135157
// No components.json file. Prompt the user to run init.

packages/shadcn/src/registry/utils.test.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, expect, it } from "vitest"
22

3-
import { getDependencyFromModuleSpecifier, isLocalFile, isUrl } from "./utils"
3+
import {
4+
getDependencyFromModuleSpecifier,
5+
isLocalFile,
6+
isUniversalRegistryItem,
7+
isUrl,
8+
} from "./utils"
49

510
describe("getDependencyFromModuleSpecifier", () => {
611
it("should return the first part of a non-scoped package with path", () => {
@@ -130,3 +135,139 @@ describe("isLocalFile", () => {
130135
expect(isLocalFile("/absolute/path")).toBe(false)
131136
})
132137
})
138+
139+
describe("isUniversalRegistryItem", () => {
140+
it("should return true when all files have targets", () => {
141+
const registryItem = {
142+
files: [
143+
{
144+
path: "file1.ts",
145+
target: "src/file1.ts",
146+
type: "registry:lib" as const,
147+
},
148+
{
149+
path: "file2.ts",
150+
target: "src/utils/file2.ts",
151+
type: "registry:lib" as const,
152+
},
153+
],
154+
}
155+
expect(isUniversalRegistryItem(registryItem)).toBe(true)
156+
})
157+
158+
it("should return false when some files lack targets", () => {
159+
const registryItem = {
160+
files: [
161+
{
162+
path: "file1.ts",
163+
target: "src/file1.ts",
164+
type: "registry:lib" as const,
165+
},
166+
{ path: "file2.ts", target: "", type: "registry:lib" as const },
167+
],
168+
}
169+
expect(isUniversalRegistryItem(registryItem)).toBe(false)
170+
})
171+
172+
it("should return false when no files have targets", () => {
173+
const registryItem = {
174+
files: [
175+
{ path: "file1.ts", target: "", type: "registry:lib" as const },
176+
{ path: "file2.ts", target: "", type: "registry:lib" as const },
177+
],
178+
}
179+
expect(isUniversalRegistryItem(registryItem)).toBe(false)
180+
})
181+
182+
it("should return false when files array is empty", () => {
183+
const registryItem = {
184+
files: [],
185+
}
186+
expect(isUniversalRegistryItem(registryItem)).toBe(false)
187+
})
188+
189+
it("should return false when files is undefined", () => {
190+
const registryItem = {}
191+
expect(isUniversalRegistryItem(registryItem)).toBe(false)
192+
})
193+
194+
it("should return false when registryItem is null", () => {
195+
expect(isUniversalRegistryItem(null)).toBe(false)
196+
})
197+
198+
it("should return false when registryItem is undefined", () => {
199+
expect(isUniversalRegistryItem(undefined)).toBe(false)
200+
})
201+
202+
it("should return false when target is null", () => {
203+
const registryItem = {
204+
files: [
205+
{
206+
path: "file1.ts",
207+
target: null as any,
208+
type: "registry:lib" as const,
209+
},
210+
],
211+
}
212+
expect(isUniversalRegistryItem(registryItem)).toBe(false)
213+
})
214+
215+
it("should return false when target is undefined", () => {
216+
const registryItem = {
217+
files: [{ path: "file1.ts", type: "registry:lib" as const }],
218+
}
219+
expect(isUniversalRegistryItem(registryItem)).toBe(false)
220+
})
221+
222+
it("should handle mixed file types correctly", () => {
223+
const registryItem = {
224+
files: [
225+
{
226+
path: "component.tsx",
227+
target: "components/ui/component.tsx",
228+
type: "registry:ui" as const,
229+
},
230+
{
231+
path: "utils.ts",
232+
target: "lib/utils.ts",
233+
type: "registry:lib" as const,
234+
},
235+
{
236+
path: "hook.ts",
237+
target: "hooks/use-something.ts",
238+
type: "registry:hook" as const,
239+
},
240+
],
241+
}
242+
expect(isUniversalRegistryItem(registryItem)).toBe(true)
243+
})
244+
245+
it("should return true when all targets are non-empty strings", () => {
246+
const registryItem = {
247+
files: [
248+
{ path: "file1.ts", target: " ", type: "registry:lib" as const }, // whitespace is truthy
249+
{ path: "file2.ts", target: "0", type: "registry:lib" as const }, // "0" is truthy
250+
],
251+
}
252+
expect(isUniversalRegistryItem(registryItem)).toBe(true)
253+
})
254+
255+
it("should handle real-world example with path traversal attempts", () => {
256+
const registryItem = {
257+
files: [
258+
{
259+
path: "malicious.ts",
260+
target: "../../../etc/passwd",
261+
type: "registry:lib" as const,
262+
},
263+
{
264+
path: "normal.ts",
265+
target: "src/normal.ts",
266+
type: "registry:lib" as const,
267+
},
268+
],
269+
}
270+
// The function should still return true - path validation is handled elsewhere
271+
expect(isUniversalRegistryItem(registryItem)).toBe(true)
272+
})
273+
})

packages/shadcn/src/registry/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,20 @@ export function isUrl(path: string) {
256256
export function isLocalFile(path: string) {
257257
return path.endsWith(".json") && !isUrl(path)
258258
}
259+
260+
/**
261+
* Check if a registry item is universal (framework-agnostic).
262+
* A universal registry item has all files with explicit targets.
263+
* It can be installed without framework detection or components.json.
264+
*/
265+
export function isUniversalRegistryItem(
266+
registryItem:
267+
| Pick<z.infer<typeof registryItemSchema>, "files">
268+
| null
269+
| undefined
270+
): boolean {
271+
return (
272+
!!registryItem?.files?.length &&
273+
registryItem.files.every((file) => !!file.target)
274+
)
275+
}

packages/shadcn/src/utils/get-config.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,64 @@ export async function getTargetStyleFromConfig(cwd: string, fallback: string) {
225225
const projectInfo = await getProjectInfo(cwd)
226226
return projectInfo?.tailwindVersion === "v4" ? "new-york-v4" : fallback
227227
}
228+
229+
type DeepPartial<T> = {
230+
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
231+
}
232+
233+
/**
234+
* Creates a config object with sensible defaults.
235+
* Useful for universal registry items that bypass framework detection.
236+
*
237+
* @param partial - Partial config values to override defaults
238+
* @returns A complete Config object
239+
*/
240+
export function createConfig(partial?: DeepPartial<Config>): Config {
241+
const defaultConfig: Config = {
242+
resolvedPaths: {
243+
cwd: process.cwd(),
244+
tailwindConfig: "",
245+
tailwindCss: "",
246+
utils: "",
247+
components: "",
248+
ui: "",
249+
lib: "",
250+
hooks: "",
251+
},
252+
style: "",
253+
tailwind: {
254+
config: "",
255+
css: "",
256+
baseColor: "",
257+
cssVariables: false,
258+
},
259+
rsc: false,
260+
tsx: true,
261+
aliases: {
262+
components: "",
263+
utils: "",
264+
},
265+
}
266+
267+
// Deep merge the partial config with defaults
268+
if (partial) {
269+
return {
270+
...defaultConfig,
271+
...partial,
272+
resolvedPaths: {
273+
...defaultConfig.resolvedPaths,
274+
...(partial.resolvedPaths || {}),
275+
},
276+
tailwind: {
277+
...defaultConfig.tailwind,
278+
...(partial.tailwind || {}),
279+
},
280+
aliases: {
281+
...defaultConfig.aliases,
282+
...(partial.aliases || {}),
283+
},
284+
}
285+
}
286+
287+
return defaultConfig
288+
}

packages/shadcn/src/utils/updaters/update-files.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export async function updateFiles(
5151

5252
const [projectInfo, baseColor] = await Promise.all([
5353
getProjectInfo(config.resolvedPaths.cwd),
54-
getRegistryBaseColor(config.tailwind.baseColor),
54+
config.tailwind.baseColor
55+
? getRegistryBaseColor(config.tailwind.baseColor)
56+
: Promise.resolve(undefined),
5557
])
5658

5759
let filesCreated: string[] = []

0 commit comments

Comments
 (0)