Skip to content

Commit df91b52

Browse files
authored
feat(shadcn): registry commands (#7497)
* feat: grunt * feat: add useCache option to fetchRegistry * feat: wip mcp * test(shadcn): add tests for build * feat(shadcn): fix import and implement verbose * chore(shadcn): add build back for now * feat(shadcn): resolve css file * chore(shadcn): add experimental tag * chore: changeset
1 parent b84c990 commit df91b52

File tree

17 files changed

+1478
-11
lines changed

17 files changed

+1478
-11
lines changed

.changeset/angry-steaks-repeat.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 registry:build command

packages/shadcn/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
"./registry": {
3535
"types": "./dist/registry/index.d.ts",
3636
"default": "./dist/registry/index.js"
37+
},
38+
"./mcp": {
39+
"types": "./dist/mcp/index.d.ts",
40+
"default": "./dist/mcp/index.js"
3741
}
3842
},
3943
"bin": "./dist/index.js",
@@ -59,6 +63,7 @@
5963
"@babel/core": "^7.22.1",
6064
"@babel/parser": "^7.22.6",
6165
"@babel/plugin-transform-typescript": "^7.22.5",
66+
"@modelcontextprotocol/sdk": "^1.10.2",
6267
"commander": "^10.0.0",
6368
"cosmiconfig": "^8.1.3",
6469
"deepmerge": "^4.3.1",
@@ -77,7 +82,8 @@
7782
"stringify-object": "^5.0.0",
7883
"ts-morph": "^18.0.0",
7984
"tsconfig-paths": "^4.2.0",
80-
"zod": "^3.20.2"
85+
"zod": "^3.20.2",
86+
"zod-to-json-schema": "^3.24.5"
8187
},
8288
"devDependencies": {
8389
"@types/babel__core": "^7.20.1",
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import * as fs from "fs/promises"
2+
import * as path from "path"
3+
import { preFlightRegistryBuild } from "@/src/preflights/preflight-registry"
4+
import { registryItemSchema, registrySchema } from "@/src/registry"
5+
import { recursivelyResolveFileImports } from "@/src/registry/utils"
6+
import * as ERRORS from "@/src/utils/errors"
7+
import { configSchema } from "@/src/utils/get-config"
8+
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
9+
import { handleError } from "@/src/utils/handle-error"
10+
import { highlighter } from "@/src/utils/highlighter"
11+
import { logger } from "@/src/utils/logger"
12+
import { spinner } from "@/src/utils/spinner"
13+
import { Command } from "commander"
14+
import { z } from "zod"
15+
16+
export const buildOptionsSchema = z.object({
17+
cwd: z.string(),
18+
registryFile: z.string(),
19+
outputDir: z.string(),
20+
verbose: z.boolean().optional().default(false),
21+
})
22+
23+
export const build = new Command()
24+
.name("registry:build")
25+
.description("builds the registry [EXPERIMENTAL]")
26+
.argument("[registry]", "path to registry.json file", "./registry.json")
27+
.option(
28+
"-o, --output <path>",
29+
"destination directory for json files",
30+
"./public/r"
31+
)
32+
.option(
33+
"-c, --cwd <cwd>",
34+
"the working directory. defaults to the current directory.",
35+
process.cwd()
36+
)
37+
.option("-v, --verbose", "verbose output")
38+
.action(async (registry: string, opts) => {
39+
await buildRegistry({
40+
cwd: path.resolve(opts.cwd),
41+
registryFile: registry,
42+
outputDir: opts.output,
43+
verbose: opts.verbose,
44+
})
45+
})
46+
47+
async function buildRegistry(opts: z.infer<typeof buildOptionsSchema>) {
48+
try {
49+
const options = buildOptionsSchema.parse(opts)
50+
51+
const [{ errors, resolvePaths, config }, projectInfo] = await Promise.all([
52+
preFlightRegistryBuild(options),
53+
getProjectInfo(options.cwd),
54+
])
55+
56+
if (errors[ERRORS.MISSING_CONFIG] || !config || !projectInfo) {
57+
logger.error(
58+
`A ${highlighter.info(
59+
"components.json"
60+
)} file is required to build the registry. Run ${highlighter.info(
61+
"shadcn init"
62+
)} to create one.`
63+
)
64+
logger.break()
65+
process.exit(1)
66+
}
67+
68+
if (errors[ERRORS.BUILD_MISSING_REGISTRY_FILE] || !resolvePaths) {
69+
logger.error(
70+
`We could not find a registry file at ${highlighter.info(
71+
path.resolve(options.cwd, options.registryFile)
72+
)}.`
73+
)
74+
logger.break()
75+
process.exit(1)
76+
}
77+
78+
const content = await fs.readFile(resolvePaths.registryFile, "utf-8")
79+
const result = registrySchema.safeParse(JSON.parse(content))
80+
81+
if (!result.success) {
82+
logger.error(
83+
`Invalid registry file found at ${highlighter.info(
84+
resolvePaths.registryFile
85+
)}.`
86+
)
87+
logger.break()
88+
process.exit(1)
89+
}
90+
91+
const buildSpinner = spinner("Building registry...")
92+
93+
// Recursively resolve the registry items.
94+
const resolvedRegistry = await resolveRegistryItems(
95+
result.data,
96+
config,
97+
projectInfo
98+
)
99+
100+
// Loop through the registry items and remove duplicates files i.e same path.
101+
for (const registryItem of resolvedRegistry.items) {
102+
// Deduplicate files
103+
registryItem.files = registryItem.files?.filter(
104+
(file, index, self) =>
105+
index === self.findIndex((t) => t.path === file.path)
106+
)
107+
108+
// Deduplicate dependencies
109+
if (registryItem.dependencies) {
110+
registryItem.dependencies = registryItem.dependencies.filter(
111+
(dep, index, self) => index === self.findIndex((d) => d === dep)
112+
)
113+
}
114+
}
115+
116+
for (const registryItem of resolvedRegistry.items) {
117+
if (!registryItem.files) {
118+
continue
119+
}
120+
121+
buildSpinner.start(`Building ${registryItem.name}...`)
122+
123+
// Add the schema to the registry item.
124+
registryItem["$schema"] =
125+
"https://ui.shadcn.com/schema/registry-item.json"
126+
127+
for (const file of registryItem.files) {
128+
const absPath = path.resolve(resolvePaths.cwd, file.path)
129+
try {
130+
const stat = await fs.stat(absPath)
131+
if (!stat.isFile()) {
132+
continue
133+
}
134+
file["content"] = await fs.readFile(absPath, "utf-8")
135+
} catch (err) {
136+
console.error("Error reading file in registry build:", absPath, err)
137+
continue
138+
}
139+
}
140+
141+
const result = registryItemSchema.safeParse(registryItem)
142+
if (!result.success) {
143+
logger.error(
144+
`Invalid registry item found for ${highlighter.info(
145+
registryItem.name
146+
)}.`
147+
)
148+
continue
149+
}
150+
151+
// Write the registry item to the output directory.
152+
await fs.writeFile(
153+
path.resolve(resolvePaths.outputDir, `${result.data.name}.json`),
154+
JSON.stringify(result.data, null, 2)
155+
)
156+
}
157+
158+
// Copy registry.json to the output directory.
159+
await fs.copyFile(
160+
resolvePaths.registryFile,
161+
path.resolve(resolvePaths.outputDir, "registry.json")
162+
)
163+
164+
buildSpinner.succeed("Building registry.")
165+
166+
if (options.verbose) {
167+
spinner(
168+
`The registry has ${highlighter.info(
169+
resolvedRegistry.items.length.toString()
170+
)} items:`
171+
).succeed()
172+
for (const item of resolvedRegistry.items) {
173+
logger.log(` - ${item.name} (${highlighter.info(item.type)})`)
174+
for (const file of item.files ?? []) {
175+
logger.log(` - ${file.path}`)
176+
}
177+
}
178+
}
179+
} catch (error) {
180+
logger.break()
181+
handleError(error)
182+
}
183+
}
184+
185+
// This reads the registry and recursively resolves the file imports.
186+
async function resolveRegistryItems(
187+
registry: z.infer<typeof registrySchema>,
188+
config: z.infer<typeof configSchema>,
189+
projectInfo: ProjectInfo
190+
): Promise<z.infer<typeof registrySchema>> {
191+
for (const item of registry.items) {
192+
if (!item.files?.length) {
193+
continue
194+
}
195+
196+
// Process all files in the array instead of just the first one
197+
for (const file of item.files) {
198+
const results = await recursivelyResolveFileImports(
199+
file.path,
200+
config,
201+
projectInfo
202+
)
203+
204+
// Remove file from results.files
205+
results.files = results.files?.filter((f) => f.path !== file.path)
206+
207+
if (results.files) {
208+
item.files.push(...results.files)
209+
}
210+
211+
if (results.dependencies) {
212+
item.dependencies = item.dependencies
213+
? item.dependencies.concat(results.dependencies)
214+
: results.dependencies
215+
}
216+
}
217+
}
218+
219+
return registry
220+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { server } from "@/src/mcp"
2+
import { handleError } from "@/src/utils/handle-error"
3+
import { logger } from "@/src/utils/logger"
4+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5+
import { Command } from "commander"
6+
7+
export const mcp = new Command()
8+
.name("registry:mcp")
9+
.description("starts the registry MCP server [EXPERIMENTAL]")
10+
.option(
11+
"-c, --cwd <cwd>",
12+
"the working directory. defaults to the current directory.",
13+
process.cwd()
14+
)
15+
.action(async () => {
16+
try {
17+
const transport = new StdioServerTransport()
18+
await server.connect(transport)
19+
} catch (error) {
20+
logger.break()
21+
handleError(error)
22+
}
23+
})

packages/shadcn/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { diff } from "@/src/commands/diff"
55
import { info } from "@/src/commands/info"
66
import { init } from "@/src/commands/init"
77
import { migrate } from "@/src/commands/migrate"
8+
import { build as registryBuild } from "@/src/commands/registry/build"
9+
import { mcp as registryMcp } from "@/src/commands/registry/mcp"
810
import { Command } from "commander"
911

1012
import packageJson from "../package.json"
@@ -30,6 +32,9 @@ async function main() {
3032
.addCommand(info)
3133
.addCommand(build)
3234

35+
// Registry commands
36+
program.addCommand(registryBuild).addCommand(registryMcp)
37+
3338
program.parse()
3439
}
3540

0 commit comments

Comments
 (0)