mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-20 22:31:35 +00:00
Compare commits
6 Commits
shadcn@2.7
...
shadcn@2.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7443edcfb0 | ||
|
|
9d9a33be52 | ||
|
|
d544a7f7a5 | ||
|
|
48fe0d709f | ||
|
|
ed244ea0b5 | ||
|
|
b8fede1742 |
@@ -15,7 +15,7 @@ export function Callout({
|
||||
return (
|
||||
<Alert
|
||||
className={cn(
|
||||
"bg-surface text-surface-foreground mt-6 w-auto border-none md:-mx-4",
|
||||
"bg-surface text-surface-foreground mt-6 w-auto border-none md:-mx-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function CodeCollapsibleWrapper({
|
||||
<Collapsible
|
||||
open={isOpened}
|
||||
onOpenChange={setIsOpened}
|
||||
className={cn("group/collapsible relative md:-mx-4", className)}
|
||||
className={cn("group/collapsible relative md:-mx-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ComponentPreviewTabs({
|
||||
</Tabs>
|
||||
<div
|
||||
data-tab={tab}
|
||||
className="data-[tab=code]:border-code relative rounded-lg border md:-mx-4"
|
||||
className="data-[tab=code]:border-code relative rounded-lg border md:-mx-1"
|
||||
>
|
||||
<div
|
||||
data-slot="preview"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ComponentPreview({
|
||||
|
||||
if (type === "block") {
|
||||
return (
|
||||
<div className="relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-4">
|
||||
<div className="relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1">
|
||||
<Image
|
||||
src={`/r/styles/new-york-v4/${name}-light.png`}
|
||||
alt={name}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ description: Every component recreated in Figma. With customizable props, typogr
|
||||
## Free
|
||||
|
||||
- [shadcn/ui design system](https://www.figma.com/community/file/1203061493325953101) by [Pietro Schirano](https://twitter.com/skirano) - A design companion for shadcn/ui. Each component was painstakingly crafted to perfectly match the code implementation.
|
||||
- [Obra shadcn/ui](https://www.figma.com/community/file/1514746685758799870/obra-shadcn-ui) by [Obra Studio](https://obra.studio/) - Carefully crafted kit designed in the philosophy of shadcn, tracks v4, MIT licensed
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"recharts": "2.15.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"shadcn": "2.7.0",
|
||||
"shadcn": "2.8.0",
|
||||
"shiki": "^1.10.1",
|
||||
"sonner": "^2.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
position: relative;
|
||||
@apply md:-mx-4;
|
||||
@apply md:-mx-1;
|
||||
|
||||
&:has([data-rehype-pretty-code-title]) [data-slot="copy-button"] {
|
||||
top: calc(var(--spacing) * 1.5) !important;
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"react-resizable-panels": "^2.0.22",
|
||||
"react-wrap-balancer": "^0.4.1",
|
||||
"recharts": "2.12.7",
|
||||
"shadcn": "2.7.0",
|
||||
"shadcn": "2.8.0",
|
||||
"sharp": "^0.32.6",
|
||||
"sonner": "^1.2.3",
|
||||
"swr": "2.2.6-beta.3",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @shadcn/ui
|
||||
|
||||
## 2.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#7720](https://github.com/shadcn-ui/ui/pull/7720) [`d544a7f7a519cd5b171d9ee7cb2fd1a226659ece`](https://github.com/shadcn-ui/ui/commit/d544a7f7a519cd5b171d9ee7cb2fd1a226659ece) Thanks [@shadcn](https://github.com/shadcn)! - refactor registry dependencies resolution
|
||||
|
||||
- [#7717](https://github.com/shadcn-ui/ui/pull/7717) [`48fe0d709fd2b244314f95f56e7afb38b117ed8a`](https://github.com/shadcn-ui/ui/commit/48fe0d709fd2b244314f95f56e7afb38b117ed8a) Thanks [@shadcn](https://github.com/shadcn)! - add support for local registry item
|
||||
|
||||
- [#6330](https://github.com/shadcn-ui/ui/pull/6330) [`ed244ea0b5abf7db50ac5fcf26e2993133fe94f7`](https://github.com/shadcn-ui/ui/commit/ed244ea0b5abf7db50ac5fcf26e2993133fe94f7) Thanks [@KitsuneDev](https://github.com/KitsuneDev)! - add support for vinxi based framework
|
||||
|
||||
## 2.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shadcn",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Add components to your apps.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
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,
|
||||
registryResolveItemsTree,
|
||||
} 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 +154,277 @@ 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",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle local files with URL dependencies", async () => {
|
||||
// Mock a URL endpoint for dependency
|
||||
const dependencyUrl = "https://example.com/dependency.json"
|
||||
server.use(
|
||||
http.get(dependencyUrl, () => {
|
||||
return HttpResponse.json({
|
||||
name: "url-dependency",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "ui/url-dependency.tsx",
|
||||
content: "// url dependency content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "component-with-url-deps.json")
|
||||
|
||||
const componentData = {
|
||||
name: "component-with-url-deps",
|
||||
type: "registry:ui",
|
||||
registryDependencies: [dependencyUrl, "button"], // Mix of URL and registry name
|
||||
files: [
|
||||
{
|
||||
path: "ui/component-with-url-deps.tsx",
|
||||
content: "// component with url deps 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: "component-with-url-deps",
|
||||
type: "registry:ui",
|
||||
registryDependencies: [dependencyUrl, "button"],
|
||||
})
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
await fs.rmdir(tempDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("registryResolveItemsTree with URL dependencies", () => {
|
||||
it("should resolve URL dependencies from local files", async () => {
|
||||
// Mock a URL endpoint for dependency
|
||||
const dependencyUrl = "https://example.com/dependency.json"
|
||||
server.use(
|
||||
http.get(dependencyUrl, () => {
|
||||
return HttpResponse.json({
|
||||
name: "url-dependency",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "ui/url-dependency.tsx",
|
||||
content: "// url dependency content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
|
||||
const tempFile = path.join(tempDir, "component-with-url-deps.json")
|
||||
|
||||
const componentData = {
|
||||
name: "component-with-url-deps",
|
||||
type: "registry:ui",
|
||||
registryDependencies: [dependencyUrl], // URL dependency
|
||||
files: [
|
||||
{
|
||||
path: "ui/component-with-url-deps.tsx",
|
||||
content: "// component with url deps content",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await fs.writeFile(tempFile, JSON.stringify(componentData, null, 2))
|
||||
|
||||
try {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
resolvedPaths: { cwd: process.cwd() },
|
||||
} as any
|
||||
|
||||
const result = await registryResolveItemsTree([tempFile], mockConfig)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.files).toBeDefined()
|
||||
// Should contain files from both the main component and its URL dependency
|
||||
const filePaths = result?.files?.map((f: any) => f.path) ?? []
|
||||
expect(filePaths).toContain("ui/component-with-url-deps.tsx")
|
||||
expect(filePaths).toContain("ui/url-dependency.tsx")
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(tempFile)
|
||||
await fs.rmdir(tempDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -263,26 +292,144 @@ export function clearRegistryCache() {
|
||||
registryCache.clear()
|
||||
}
|
||||
|
||||
async function resolveDependenciesRecursively(
|
||||
dependencies: string[],
|
||||
config?: Config,
|
||||
visited: Set<string> = new Set()
|
||||
): Promise<{
|
||||
items: z.infer<typeof registryItemSchema>[]
|
||||
registryNames: string[]
|
||||
}> {
|
||||
const items: z.infer<typeof registryItemSchema>[] = []
|
||||
const registryNames: string[] = []
|
||||
|
||||
for (const dep of dependencies) {
|
||||
// Avoid infinite recursion.
|
||||
if (visited.has(dep)) {
|
||||
continue
|
||||
}
|
||||
visited.add(dep)
|
||||
|
||||
if (isUrl(dep) || isLocalFile(dep)) {
|
||||
const item = await getRegistryItem(dep, "")
|
||||
if (item) {
|
||||
items.push(item)
|
||||
if (item.registryDependencies) {
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
item.registryDependencies,
|
||||
config,
|
||||
visited
|
||||
)
|
||||
items.push(...nested.items)
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Registry name - add it to the list
|
||||
registryNames.push(dep)
|
||||
|
||||
// If we have config, we can also fetch the item to get its dependencies
|
||||
if (config) {
|
||||
const style = config.resolvedPaths?.cwd
|
||||
? await getTargetStyleFromConfig(
|
||||
config.resolvedPaths.cwd,
|
||||
config.style
|
||||
)
|
||||
: config.style
|
||||
|
||||
try {
|
||||
const item = await getRegistryItem(dep, style)
|
||||
if (item && item.registryDependencies) {
|
||||
const nested = await resolveDependenciesRecursively(
|
||||
item.registryDependencies,
|
||||
config,
|
||||
visited
|
||||
)
|
||||
items.push(...nested.items)
|
||||
registryNames.push(...nested.registryNames)
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't fetch the registry item, that's okay - we'll still include the name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { items, registryNames }
|
||||
}
|
||||
|
||||
export async function registryResolveItemsTree(
|
||||
names: z.infer<typeof registryItemSchema>["name"][],
|
||||
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 and URLs directly, collecting their dependencies.
|
||||
const allDependencies: string[] = []
|
||||
|
||||
for (const localFile of localFiles) {
|
||||
const item = await getRegistryItem(localFile, "")
|
||||
if (item) {
|
||||
payload.push(item)
|
||||
if (item.registryDependencies) {
|
||||
allDependencies.push(...item.registryDependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we're resolving the index, we want it to go first.
|
||||
if (names.includes("index")) {
|
||||
names.unshift("index")
|
||||
for (const url of urls) {
|
||||
const item = await getRegistryItem(url, "")
|
||||
if (item) {
|
||||
payload.push(item)
|
||||
if (item.registryDependencies) {
|
||||
allDependencies.push(...item.registryDependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let registryItems = await resolveRegistryItems(names, config)
|
||||
let result = await fetchRegistry(registryItems)
|
||||
const payload = z.array(registryItemSchema).parse(result)
|
||||
// Recursively resolve all dependencies.
|
||||
const { items: dependencyItems, registryNames: dependencyRegistryNames } =
|
||||
await resolveDependenciesRecursively(allDependencies, config)
|
||||
|
||||
if (!payload) {
|
||||
payload.push(...dependencyItems)
|
||||
|
||||
// Handle registry names using existing resolveRegistryItems logic.
|
||||
const allRegistryNames = [...registryNames, ...dependencyRegistryNames]
|
||||
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 +437,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) {
|
||||
@@ -352,44 +499,17 @@ async function resolveRegistryDependencies(
|
||||
url: string,
|
||||
config: Config
|
||||
): Promise<string[]> {
|
||||
const visited = new Set<string>()
|
||||
const payload: string[] = []
|
||||
const { registryNames } = await resolveDependenciesRecursively([url], config)
|
||||
|
||||
const style = config.resolvedPaths?.cwd
|
||||
? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style)
|
||||
: config.style
|
||||
|
||||
async function resolveDependencies(itemUrl: string) {
|
||||
const url = getRegistryUrl(
|
||||
isUrl(itemUrl) ? itemUrl : `styles/${style}/${itemUrl}.json`
|
||||
)
|
||||
const urls = registryNames.map((name) =>
|
||||
getRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`)
|
||||
)
|
||||
|
||||
if (visited.has(url)) {
|
||||
return
|
||||
}
|
||||
|
||||
visited.add(url)
|
||||
|
||||
try {
|
||||
const [result] = await fetchRegistry([url])
|
||||
const item = registryItemSchema.parse(result)
|
||||
payload.push(url)
|
||||
|
||||
if (item.registryDependencies) {
|
||||
for (const dependency of item.registryDependencies) {
|
||||
await resolveDependencies(dependency)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching or parsing registry item at ${itemUrl}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await resolveDependencies(url)
|
||||
return Array.from(new Set(payload))
|
||||
return Array.from(new Set(urls))
|
||||
}
|
||||
|
||||
export async function registryGetTheme(name: string, config: Config) {
|
||||
@@ -495,7 +615,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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -146,6 +146,20 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
|
||||
return type
|
||||
}
|
||||
|
||||
// Vinxi-based (such as @tanstack/start and @solidjs/solid-start)
|
||||
// They are vite-based, and the same configurations used for Vite should work flawlessly
|
||||
const appConfig = configFiles.find((file) => file.startsWith("app.config"))
|
||||
if (appConfig?.length) {
|
||||
const appConfigContents = await fs.readFile(
|
||||
path.resolve(cwd, appConfig),
|
||||
"utf8"
|
||||
)
|
||||
if (appConfigContents.includes("defineConfig")) {
|
||||
type.framework = FRAMEWORKS["vite"]
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
// Expo.
|
||||
if (packageJson?.dependencies?.expo) {
|
||||
type.framework = FRAMEWORKS["expo"]
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -322,7 +322,7 @@ importers:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
shadcn:
|
||||
specifier: 2.7.0
|
||||
specifier: 2.8.0
|
||||
version: link:../../packages/shadcn
|
||||
shiki:
|
||||
specifier: ^1.10.1
|
||||
@@ -602,7 +602,7 @@ importers:
|
||||
specifier: 2.12.7
|
||||
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
shadcn:
|
||||
specifier: 2.7.0
|
||||
specifier: 2.8.0
|
||||
version: link:../../packages/shadcn
|
||||
sharp:
|
||||
specifier: ^0.32.6
|
||||
|
||||
Reference in New Issue
Block a user