feat(shadcn): add file support (#7717)

* feat(shadcn): add file support

* fix: format

* fix: types

* feat(shadcn): update init and add description

* docs: update docs for cli

* chore: add changeset
This commit is contained in:
shadcn
2025-07-01 17:06:17 +04:00
committed by GitHub
parent ed244ea0b5
commit 48fe0d709f
8 changed files with 396 additions and 34 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add support for local registry item

View File

@@ -21,15 +21,14 @@ Usage: shadcn init [options] [components...]
initialize your project and install dependencies
Arguments:
components the components to add or a url to the component.
components name, url or local path to component
Options:
-t, --template <template> the template to use. (next, next-monorepo)
-b, --base-color <base-color> the base color to use. (neutral, gray, zinc, stone, slate)
-y, --yes skip confirmation prompt. (default: true)
-f, --force force overwrite of existing configuration. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory. (default:
"/Users/shadcn/Code/shadcn/ui/packages/shadcn")
-c, --cwd <cwd> the working directory. defaults to the current directory.
-s, --silent mute output. (default: false)
--src-dir use the src directory when creating a new project. (default: false)
--no-src-dir do not use the src directory when creating a new project.
@@ -54,12 +53,12 @@ Usage: shadcn add [options] [components...]
add a component to your project
Arguments:
components the components to add or a url to the component.
components name, url or local path to component
Options:
-y, --yes skip confirmation prompt. (default: false)
-o, --overwrite overwrite existing files. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory. (default: "/Users/shadcn/Desktop")
-c, --cwd <cwd> the working directory. defaults to the current directory.
-a, --all add all available components (default: false)
-p, --path <path> the path to add the component to.
-s, --silent mute output. (default: false)
@@ -92,8 +91,7 @@ Arguments:
Options:
-o, --output <path> destination directory for json files (default: "./public/r")
-c, --cwd <cwd> the working directory. defaults to the current directory. (default:
"/Users/shadcn/Code/shadcn/ui/packages/shadcn")
-c, --cwd <cwd> the working directory. defaults to the current directory.
-h, --help display help for command
```

View File

@@ -1,8 +1,9 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightAdd } from "@/src/preflights/preflight-add"
import { getRegistryIndex, getRegistryItem, isUrl } from "@/src/registry/api"
import { getRegistryIndex, getRegistryItem } from "@/src/registry/api"
import { registryItemTypeSchema } from "@/src/registry/schema"
import { isLocalFile, 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"
@@ -46,10 +47,7 @@ export const addOptionsSchema = z.object({
export const add = new Command()
.name("add")
.description("add a component to your project")
.argument(
"[components...]",
"the components to add or a url to the component."
)
.argument("[components...]", "names, url or local path to component")
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-o, --overwrite", "overwrite existing files.", false)
.option(
@@ -81,7 +79,10 @@ export const add = new Command()
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
if (components.length > 0 && isUrl(components[0])) {
if (
components.length > 0 &&
(isUrl(components[0]) || isLocalFile(components[0]))
) {
const item = await getRegistryItem(components[0], "")
itemType = item?.type
}

View File

@@ -6,8 +6,8 @@ import {
getRegistryBaseColors,
getRegistryItem,
getRegistryStyles,
isUrl,
} from "@/src/registry/api"
import { isLocalFile, isUrl } from "@/src/registry/utils"
import { addComponents } from "@/src/utils/add-components"
import { TEMPLATES, createProject } from "@/src/utils/create-project"
import * as ERRORS from "@/src/utils/errors"
@@ -82,10 +82,7 @@ export const initOptionsSchema = z.object({
export const init = new Command()
.name("init")
.description("initialize your project and install dependencies")
.argument(
"[components...]",
"the components to add or a url to the component."
)
.argument("[components...]", "names, url or local path to component")
.option(
"-t, --template <template>",
"the template to use. (next, next-monorepo)"
@@ -128,7 +125,10 @@ export const init = new Command()
// We need to check if we're initializing with a new style.
// We fetch the payload of the first item.
// This is okay since the request is cached and deduped.
if (components.length > 0 && isUrl(components[0])) {
if (
components.length > 0 &&
(isUrl(components[0]) || isLocalFile(components[0]))
) {
const item = await getRegistryItem(components[0], "")
// Skip base color if style.

View File

@@ -1,12 +1,49 @@
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import { HttpResponse, http } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest"
import { clearRegistryCache, fetchRegistry } from "./api"
import { clearRegistryCache, fetchRegistry, getRegistryItem } from "./api"
// Mock the handleError function to prevent process.exit in tests
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn(),
}))
// Mock the logger to prevent console output in tests
vi.mock("@/src/utils/logger", () => ({
logger: {
error: vi.fn(),
break: vi.fn(),
log: vi.fn(),
},
}))
const REGISTRY_URL = "https://ui.shadcn.com/r"
const server = setupServer(
http.get(`${REGISTRY_URL}/index.json`, () => {
return HttpResponse.json([
{
name: "button",
type: "registry:ui",
},
{
name: "card",
type: "registry:ui",
},
])
}),
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, () => {
return HttpResponse.json({
name: "button",
@@ -112,3 +149,164 @@ describe("fetchRegistry", () => {
expect(result[1]).toMatchObject({ name: "card" })
})
})
describe("getRegistryItem with local files", () => {
it("should read and parse a valid local JSON file", async () => {
// Create a temporary file
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "test-component.json")
const componentData = {
name: "test-component",
type: "registry:ui",
dependencies: ["@radix-ui/react-dialog"],
files: [
{
path: "ui/test-component.tsx",
content: "// test component content",
type: "registry:ui",
},
],
}
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toMatchObject({
name: "test-component",
type: "registry:ui",
dependencies: ["@radix-ui/react-dialog"],
files: [
{
path: "ui/test-component.tsx",
content: "// test component content",
type: "registry:ui",
},
],
})
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should handle relative paths", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "relative-component.json")
const componentData = {
name: "relative-component",
type: "registry:ui",
files: [],
}
await fs.writeFile(tempFile, JSON.stringify(componentData))
try {
// Change to temp directory to test relative path
const originalCwd = process.cwd()
process.chdir(tempDir)
const result = await getRegistryItem(
"./relative-component.json",
"unused-style"
)
expect(result).toMatchObject({
name: "relative-component",
type: "registry:ui",
})
process.chdir(originalCwd)
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should handle tilde (~) home directory paths", async () => {
const os = await import("os")
const homeDir = os.homedir()
const tempFile = path.join(homeDir, "shadcn-test-tilde.json")
const componentData = {
name: "tilde-component",
type: "registry:ui",
files: [],
}
await fs.writeFile(tempFile, JSON.stringify(componentData))
try {
// Test with tilde path
const tildeePath = "~/shadcn-test-tilde.json"
const result = await getRegistryItem(tildeePath, "unused-style")
expect(result).toMatchObject({
name: "tilde-component",
type: "registry:ui",
})
} finally {
// Clean up
await fs.unlink(tempFile)
}
})
it("should return null for non-existent files", async () => {
const result = await getRegistryItem(
"/non/existent/file.json",
"unused-style"
)
expect(result).toBe(null)
})
it("should return null for invalid JSON", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid.json")
await fs.writeFile(tempFile, "{ invalid json }")
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toBe(null)
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should return null for JSON that doesn't match registry schema", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const tempFile = path.join(tempDir, "invalid-schema.json")
const invalidData = {
notAValidRegistryItem: true,
missing: "required fields",
}
await fs.writeFile(tempFile, JSON.stringify(invalidData))
try {
const result = await getRegistryItem(tempFile, "unused-style")
expect(result).toBe(null)
} finally {
// Clean up
await fs.unlink(tempFile)
await fs.rmdir(tempDir)
}
})
it("should still handle URLs and component names", async () => {
// Test that existing functionality still works
const result = await getRegistryItem("button", "new-york")
expect(result).toMatchObject({
name: "button",
type: "registry:ui",
})
})
})

View File

@@ -1,4 +1,7 @@
import { promises as fs } from "fs"
import { homedir } from "os"
import path from "path"
import { isLocalFile } from "@/src/registry/utils"
import { Config, getTargetStyleFromConfig } from "@/src/utils/get-config"
import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
@@ -85,6 +88,12 @@ export async function getRegistryIcons() {
export async function getRegistryItem(name: string, style: string) {
try {
// Handle local file paths
if (isLocalFile(name)) {
return await getLocalRegistryItem(name)
}
// Handle URLs and component names
const [result] = await fetchRegistry([
isUrl(name) ? name : `styles/${style}/${name}.json`,
])
@@ -97,6 +106,26 @@ export async function getRegistryItem(name: string, style: string) {
}
}
async function getLocalRegistryItem(filePath: string) {
try {
// Handle tilde expansion for home directory
let expandedPath = filePath
if (filePath.startsWith("~/")) {
expandedPath = path.join(homedir(), filePath.slice(2))
}
const resolvedPath = path.resolve(expandedPath)
const content = await fs.readFile(resolvedPath, "utf8")
const parsed = JSON.parse(content)
return registryItemSchema.parse(parsed)
} catch (error) {
logger.error(`Failed to read local registry file: ${filePath}`)
handleError(error)
return null
}
}
export async function getRegistryBaseColors() {
return BASE_COLORS
}
@@ -268,21 +297,76 @@ export async function registryResolveItemsTree(
config: Config
) {
try {
const index = await getRegistryIndex()
if (!index) {
return null
// Separate local files, URLs, and registry names
const localFiles = names.filter((name) => isLocalFile(name))
const urls = names.filter((name) => isUrl(name))
const registryNames = names.filter(
(name) => !isLocalFile(name) && !isUrl(name)
)
const payload: z.infer<typeof registryItemSchema>[] = []
// Handle local files directly and collect their registry dependencies
const registryDependenciesFromLocalFiles: string[] = []
for (const localFile of localFiles) {
const item = await getRegistryItem(localFile, "")
if (item) {
payload.push(item)
// Collect registry dependencies from local files
if (item.registryDependencies) {
registryDependenciesFromLocalFiles.push(...item.registryDependencies)
}
}
}
// If we're resolving the index, we want it to go first.
if (names.includes("index")) {
names.unshift("index")
// Handle URLs directly and collect their registry dependencies
const registryDependenciesFromUrls: string[] = []
for (const url of urls) {
const item = await getRegistryItem(url, "")
if (item) {
payload.push(item)
// Collect registry dependencies from URLs
if (item.registryDependencies) {
registryDependenciesFromUrls.push(...item.registryDependencies)
}
}
}
let registryItems = await resolveRegistryItems(names, config)
let result = await fetchRegistry(registryItems)
const payload = z.array(registryItemSchema).parse(result)
// Combine all registry names (original + dependencies from local files/URLs)
const allRegistryNames = [
...registryNames,
...registryDependenciesFromLocalFiles,
...registryDependenciesFromUrls,
]
if (!payload) {
// Handle registry names with existing logic
if (allRegistryNames.length > 0) {
const index = await getRegistryIndex()
if (!index) {
// If we only have local files or URLs, that's fine
if (payload.length === 0) {
return null
}
} else {
// Remove duplicates
const uniqueRegistryNames = Array.from(new Set(allRegistryNames))
// If we're resolving the index, we want it to go first.
if (uniqueRegistryNames.includes("index")) {
uniqueRegistryNames.unshift("index")
}
let registryItems = await resolveRegistryItems(
uniqueRegistryNames,
config
)
let result = await fetchRegistry(registryItems)
const registryPayload = z.array(registryItemSchema).parse(result)
payload.push(...registryPayload)
}
}
if (!payload.length) {
return null
}
@@ -290,7 +374,7 @@ export async function registryResolveItemsTree(
// the theme item if a base color is provided.
// We do this for index only.
// Other components will ship with their theme tokens.
if (names.includes("index")) {
if (allRegistryNames.includes("index")) {
if (config.tailwind.baseColor) {
const theme = await registryGetTheme(config.tailwind.baseColor, config)
if (theme) {
@@ -495,7 +579,13 @@ export function isUrl(path: string) {
// TODO: We're double-fetching here. Use a cache.
export async function resolveRegistryItems(names: string[], config: Config) {
let registryDependencies: string[] = []
for (const name of names) {
// Filter out local files and URLs - these should be handled directly by getRegistryItem
const registryNames = names.filter(
(name) => !isLocalFile(name) && !isUrl(name)
)
for (const name of registryNames) {
const itemRegistryDependencies = await resolveRegistryDependencies(
name,
config

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { getDependencyFromModuleSpecifier } from "./utils"
import { getDependencyFromModuleSpecifier, isLocalFile, isUrl } from "./utils"
describe("getDependencyFromModuleSpecifier", () => {
it("should return the first part of a non-scoped package with path", () => {
@@ -74,3 +74,59 @@ describe("getDependencyFromModuleSpecifier", () => {
)
})
})
describe("isUrl", () => {
it("should return true for valid URLs", () => {
expect(isUrl("https://example.com")).toBe(true)
expect(isUrl("http://example.com")).toBe(true)
expect(isUrl("https://example.com/path")).toBe(true)
expect(isUrl("https://subdomain.example.com")).toBe(true)
expect(isUrl("https://ui.shadcn.com/r/styles/new-york/button.json")).toBe(
true
)
})
it("should return false for non-URLs", () => {
expect(isUrl("./local-file.json")).toBe(false)
expect(isUrl("../relative/path.json")).toBe(false)
expect(isUrl("/absolute/path.json")).toBe(false)
expect(isUrl("component-name")).toBe(false)
expect(isUrl("")).toBe(false)
expect(isUrl("just-text")).toBe(false)
})
})
describe("isLocalFile", () => {
it("should return true for local JSON files", () => {
expect(isLocalFile("./component.json")).toBe(true)
expect(isLocalFile("../shared/button.json")).toBe(true)
expect(isLocalFile("/absolute/path/card.json")).toBe(true)
expect(isLocalFile("local-component.json")).toBe(true)
expect(isLocalFile("nested/directory/dialog.json")).toBe(true)
expect(isLocalFile("~/Desktop/component.json")).toBe(true)
expect(isLocalFile("~/Documents/shared/button.json")).toBe(true)
})
it("should return false for URLs ending with .json", () => {
expect(isLocalFile("https://example.com/component.json")).toBe(false)
expect(isLocalFile("http://registry.com/button.json")).toBe(false)
expect(
isLocalFile("https://ui.shadcn.com/r/styles/new-york/button.json")
).toBe(false)
})
it("should return false for non-JSON files", () => {
expect(isLocalFile("./component.tsx")).toBe(false)
expect(isLocalFile("../shared/button.ts")).toBe(false)
expect(isLocalFile("/absolute/path/card.js")).toBe(false)
expect(isLocalFile("local-component.css")).toBe(false)
expect(isLocalFile("component-name")).toBe(false)
expect(isLocalFile("")).toBe(false)
})
it("should return false for directory paths", () => {
expect(isLocalFile("./components/")).toBe(false)
expect(isLocalFile("../shared")).toBe(false)
expect(isLocalFile("/absolute/path")).toBe(false)
})
})

View File

@@ -242,3 +242,17 @@ function determineFileType(
return "registry:component"
}
// Additional utility functions for local file support
export function isUrl(path: string) {
try {
new URL(path)
return true
} catch (error) {
return false
}
}
export function isLocalFile(path: string) {
return path.endsWith(".json") && !isUrl(path)
}