From 0e480d4caa63e75bb0ef014a282dd5e806c8bbe2 Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 19 May 2026 21:42:02 +0400 Subject: [PATCH 1/7] feat: implement registry include --- apps/v4/public/schema/registry.json | 17 +- packages/shadcn/src/commands/build.ts | 77 +-- packages/shadcn/src/registry/index.ts | 6 + packages/shadcn/src/registry/loader.test.ts | 374 ++++++++++++++ packages/shadcn/src/registry/loader.ts | 509 ++++++++++++++++++++ packages/shadcn/src/registry/schema.test.ts | 36 +- packages/shadcn/src/registry/schema.ts | 36 +- 7 files changed, 1012 insertions(+), 43 deletions(-) create mode 100644 packages/shadcn/src/registry/loader.test.ts create mode 100644 packages/shadcn/src/registry/loader.ts diff --git a/apps/v4/public/schema/registry.json b/apps/v4/public/schema/registry.json index 6f21ab4b24e..3201ec6c2f0 100644 --- a/apps/v4/public/schema/registry.json +++ b/apps/v4/public/schema/registry.json @@ -3,20 +3,31 @@ "description": "A shadcn registry of components, hooks, pages, etc.", "type": "object", "properties": { + "$schema": { + "type": "string" + }, "name": { + "description": "The registry name. Required when this file is used as the root registry, optional for included registry chunks.", "type": "string" }, "homepage": { + "description": "The registry homepage. Required when this file is used as the root registry, optional for included registry chunks.", "type": "string" }, + "include": { + "type": "array", + "description": "An array of relative paths to registry.json files to include in this registry.", + "items": { + "type": "string" + } + }, "items": { "type": "array", + "default": [], "items": { "$ref": "https://ui.shadcn.com/schema/registry-item.json" } } }, - "required": ["name", "homepage", "items"], - "uniqueItems": true, - "minItems": 1 + "anyOf": [{ "required": ["items"] }, { "required": ["include"] }] } diff --git a/packages/shadcn/src/commands/build.ts b/packages/shadcn/src/commands/build.ts index 5515f946893..2294ba36e80 100644 --- a/packages/shadcn/src/commands/build.ts +++ b/packages/shadcn/src/commands/build.ts @@ -1,8 +1,12 @@ import * as fs from "fs/promises" import * as path from "path" import { preFlightBuild } from "@/src/preflights/preflight-build" -import { SHADCN_URL } from "@/src/registry/constants" -import { registryItemSchema, registrySchema } from "@/src/schema" +import { + createRegistryCatalog, + createRegistryItem, + readRegistryWithIncludes, +} from "@/src/registry/loader" +import { registryItemSchema } from "@/src/schema" import { handleError } from "@/src/utils/handle-error" import { highlighter } from "@/src/utils/highlighter" import { logger } from "@/src/utils/logger" @@ -30,46 +34,44 @@ export const build = new Command() "the working directory. defaults to the current directory.", process.cwd() ) - .action(async (registry: string, opts) => { + .action(async (registryFile: string, opts) => { try { const options = buildOptionsSchema.parse({ cwd: path.resolve(opts.cwd), - registryFile: registry, + registryFile, outputDir: opts.output, }) const { resolvePaths } = await preFlightBuild(options) - const content = await fs.readFile(resolvePaths.registryFile, "utf-8") - - const result = registrySchema.safeParse(JSON.parse(content)) - - if (!result.success) { - logger.error( - `Invalid registry file found at ${highlighter.info( - resolvePaths.registryFile - )}.` - ) - process.exit(1) - } + const registryResult = await readRegistryWithIncludes( + resolvePaths.registryFile, + { + cwd: resolvePaths.cwd, + } + ) + const resolvedRegistry = registryResult.registry + const registryRootDir = registryResult.usesInclude + ? path.dirname(resolvePaths.registryFile) + : resolvePaths.cwd + const registryCatalog = createRegistryCatalog( + registryResult, + registryRootDir, + resolvePaths.cwd + ) const buildSpinner = spinner("Building registry...") - for (const registryItem of result.data.items) { + for (const registryItem of resolvedRegistry.items) { buildSpinner.start(`Building ${registryItem.name}...`) - // Add the schema to the registry item. - registryItem["$schema"] = - "https://ui.shadcn.com/schema/registry-item.json" - - // Loop through each file in the files array. - for (const file of registryItem.files ?? []) { - file["content"] = await fs.readFile( - path.resolve(resolvePaths.cwd, file.path), - "utf-8" - ) - } + const registryItemForBuild = await createRegistryItem( + registryItem, + registryResult, + registryRootDir, + resolvePaths.cwd + ) // Validate the registry item. - const result = registryItemSchema.safeParse(registryItem) + const result = registryItemSchema.safeParse(registryItemForBuild) if (!result.success) { logger.error( `Invalid registry item found for ${highlighter.info( @@ -86,11 +88,18 @@ export const build = new Command() ) } - // Copy registry.json to the output directory. - await fs.copyFile( - resolvePaths.registryFile, - path.resolve(resolvePaths.outputDir, "registry.json") - ) + if (registryResult.usesInclude) { + await fs.writeFile( + path.resolve(resolvePaths.outputDir, "registry.json"), + JSON.stringify(registryCatalog, null, 2) + ) + } else { + // Copy registry.json to the output directory. + await fs.copyFile( + resolvePaths.registryFile, + path.resolve(resolvePaths.outputDir, "registry.json") + ) + } buildSpinner.succeed("Building registry.") } catch (error) { diff --git a/packages/shadcn/src/registry/index.ts b/packages/shadcn/src/registry/index.ts index b3f0a6c76bd..faf7dc8a605 100644 --- a/packages/shadcn/src/registry/index.ts +++ b/packages/shadcn/src/registry/index.ts @@ -8,6 +8,12 @@ export { export { searchRegistries } from "./search" +export { + loadRegistry, + loadRegistryItem, + type LoadRegistryOptions, +} from "./loader" + export { RegistryError, RegistryNotFoundError, diff --git a/packages/shadcn/src/registry/loader.test.ts b/packages/shadcn/src/registry/loader.test.ts new file mode 100644 index 00000000000..14f0d3cb1ff --- /dev/null +++ b/packages/shadcn/src/registry/loader.test.ts @@ -0,0 +1,374 @@ +import * as fs from "fs/promises" +import { tmpdir } from "os" +import * as path from "path" +import { describe, expect, it, vi } from "vitest" + +import { + getRegistryItemFileRootPath, + getRegistryItemFileSource, + loadRegistry, + loadRegistryItem, + readRegistryWithIncludes, +} from "./loader" + +describe("readRegistryWithIncludes", () => { + it("resolves explicit registry.json includes before local items", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/ui/registry.json", "registry/hooks/registry.json"], + items: [ + { + name: "root-item", + type: "registry:item", + }, + ], + }), + "registry/ui/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + "registry/ui/button.tsx": "export function Button() {}", + "registry/hooks/registry.json": JSON.stringify({ + name: "example-hooks", + homepage: "https://example.com", + items: [ + { + name: "use-toggle", + type: "registry:hook", + files: [ + { + path: "use-toggle.ts", + type: "registry:hook", + }, + ], + }, + ], + }), + "registry/hooks/use-toggle.ts": "export function useToggle() {}", + }) + + const result = await readRegistryWithIncludes("registry.json", { cwd }) + + expect(result.usesInclude).toBe(true) + expect(result.registry).toMatchObject({ + name: "example", + homepage: "https://example.com", + items: [ + { name: "button" }, + { name: "use-toggle" }, + { name: "root-item" }, + ], + }) + expect(result.registry).not.toHaveProperty("include") + expect( + getRegistryItemFileSource( + { name: "button" }, + "button.tsx", + result.itemSources, + cwd + ) + ).toBe(path.join(cwd, "registry/ui/button.tsx")) + expect( + getRegistryItemFileRootPath( + { name: "button" }, + "button.tsx", + result.itemSources, + cwd, + cwd + ) + ).toBe("registry/ui/button.tsx") + }) + + it("rejects root registries without name and homepage", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + include: ["registry/ui/registry.json"], + }), + "registry/ui/registry.json": JSON.stringify({ + items: [], + }), + }) + + await expect( + readRegistryWithIncludes("registry.json", { cwd }) + ).rejects.toThrow('missing required fields "name", "homepage"') + }) + + it("rejects include targets that are not registry.json files", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/ui.json"], + items: [], + }), + "registry/ui.json": JSON.stringify({ + name: "example-ui", + homepage: "https://example.com", + items: [], + }), + }) + + await expect( + readRegistryWithIncludes("registry.json", { cwd }) + ).rejects.toThrow("must explicitly reference a registry.json file") + }) + + it("rejects duplicate item names in the resolved catalog", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/ui/registry.json"], + items: [ + { + name: "button", + type: "registry:block", + }, + ], + }), + "registry/ui/registry.json": JSON.stringify({ + name: "example-ui", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:ui", + }, + ], + }), + }) + + await expect( + readRegistryWithIncludes("registry.json", { cwd }) + ).rejects.toThrow('Duplicate registry item name "button"') + }) + + it("rejects parent traversal in item file paths for include composition", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/ui/registry.json"], + items: [], + }), + "registry/ui/registry.json": JSON.stringify({ + name: "example-ui", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "../button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + }) + + await expect( + readRegistryWithIncludes("registry.json", { cwd }) + ).rejects.toThrow("file paths cannot use parent-directory traversal") + }) + + it("keeps legacy single-file registries compatible", async () => { + const cwd = await createFixture({ + "registry.flat.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "../button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + }) + + const result = await readRegistryWithIncludes("registry.flat.json", { + cwd, + }) + + expect(result.usesInclude).toBe(false) + expect(result.registry.items).toHaveLength(1) + }) + + it("keeps legacy file paths cwd-relative for nested single-file registries", async () => { + const cwd = await createFixture({ + "registry/registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "components/button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + "components/button.tsx": "export function Button() {}", + }) + + const registry = await loadRegistry({ + cwd, + registryFile: "registry/registry.json", + }) + + expect(registry.items[0].files?.[0].path).toBe("components/button.tsx") + }) + + it("warns for non-namespaced dependencies missing from the resolved catalog", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/blocks/registry.json"], + items: [], + }), + "registry/blocks/registry.json": JSON.stringify({ + name: "example-blocks", + homepage: "https://example.com", + items: [ + { + name: "login-form", + type: "registry:block", + registryDependencies: ["button"], + }, + ], + }), + }) + + await readRegistryWithIncludes("registry.json", { cwd }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('depends on "button"') + ) + warn.mockRestore() + }) + + it("resolves a local registry catalog for dynamic registry routes", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/ui/registry.json"], + }), + "registry/ui/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + "registry/ui/button.tsx": "export function Button() {}", + }) + + const registry = await loadRegistry({ cwd }) + + expect(registry).toMatchObject({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "button", + files: [ + { + path: "registry/ui/button.tsx", + }, + ], + }, + ], + }) + expect(registry).not.toHaveProperty("include") + expect(registry.items[0].files?.[0]).not.toHaveProperty("content") + }) + + it("resolves a local registry item for dynamic item routes", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry/ui/registry.json"], + }), + "registry/ui/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + "registry/ui/button.tsx": "export function Button() {}", + }) + + const item = await loadRegistryItem("button", { + cwd, + }) + + expect(item).toMatchObject({ + $schema: "https://ui.shadcn.com/schema/registry-item.json", + name: "button", + files: [ + { + path: "registry/ui/button.tsx", + content: "export function Button() {}", + }, + ], + }) + }) +}) + +async function createFixture(files: Record) { + const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-registry-")) + + await Promise.all( + Object.entries(files).map(async ([filePath, content]) => { + const targetPath = path.join(cwd, filePath) + await fs.mkdir(path.dirname(targetPath), { recursive: true }) + await fs.writeFile(targetPath, content) + }) + ) + + return cwd +} diff --git a/packages/shadcn/src/registry/loader.ts b/packages/shadcn/src/registry/loader.ts new file mode 100644 index 00000000000..5f5e348d8fe --- /dev/null +++ b/packages/shadcn/src/registry/loader.ts @@ -0,0 +1,509 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { isUrl } from "@/src/registry/utils" +import { + registryChunkSchema, + registryItemSchema, + registrySchema, + type Registry, + type RegistryItem, +} from "@/src/schema" +import { z } from "zod" + +type RegistryChunk = z.infer + +export type RegistryItemSource = { + registryFile: string + registryDir: string + itemIndex: number +} + +export type RegistryLoadResult = { + registry: Registry + itemSources: Map + usesInclude: boolean +} + +export type RegistryLoadOptions = { + cwd: string +} + +export type LoadRegistryOptions = { + cwd?: string + registryFile?: string +} + +export async function loadRegistry(options?: LoadRegistryOptions) { + const { cwd, registryFile } = resolveLoadRegistryOptions(options) + const result = await readRegistryWithIncludes(registryFile, { cwd }) + const rootDir = getRegistryRootDir(result, cwd, registryFile) + + return createRegistryCatalog(result, rootDir, cwd) +} + +export async function loadRegistryItem( + itemName: string, + options?: LoadRegistryOptions +) { + const { cwd, registryFile } = resolveLoadRegistryOptions(options) + const result = await readRegistryWithIncludes(registryFile, { cwd }) + const item = result.registry.items.find((item) => item.name === itemName) + + if (!item) { + throw new Error(`Registry item "${itemName}" was not found.`) + } + + const rootDir = getRegistryRootDir(result, cwd, registryFile) + + return createRegistryItem(item, result, rootDir, cwd) +} + +export async function readRegistryWithIncludes( + registryFile: string, + options: RegistryLoadOptions +): Promise { + const rootFile = path.resolve(options.cwd, registryFile) + const content = await readRegistryJson(rootFile) + const rootRegistry = parseRegistry(content, rootFile) + validateRootRegistry(rootRegistry, rootFile) + const context = { + cwd: path.resolve(options.cwd), + itemSources: new Map(), + itemSourcesByItem: new Map(), + } + const usesInclude = !!rootRegistry.include?.length + + if (!usesInclude) { + rootRegistry.items.forEach((item, itemIndex) => { + const source = { + registryFile: rootFile, + registryDir: context.cwd, + itemIndex, + } + context.itemSources.set(item.name, source) + context.itemSourcesByItem.set(item, source) + }) + + return { + registry: rootRegistry, + itemSources: context.itemSources, + usesInclude, + } + } + + if (path.basename(rootFile) !== "registry.json") { + throw new Error( + `Invalid registry file at ${rootFile}: registries that use include must be named registry.json.` + ) + } + + const result = await readRegistryFile(rootFile, rootRegistry, context, []) + + validateDuplicateItems(result.items, context.itemSourcesByItem) + validateRegistryDependencies(result.items, context.itemSources) + + const { include, ...registry } = result + validateRootRegistry(registry, rootFile) + + return { + registry, + itemSources: context.itemSources, + usesInclude, + } +} + +export function createRegistryCatalog( + result: RegistryLoadResult, + rootDir: string, + fallbackDir: string +) { + return { + ...result.registry, + items: result.registry.items.map((item) => + normalizeRegistryItemFilePaths( + item, + result.itemSources, + rootDir, + fallbackDir + ) + ), + } +} + +export async function createRegistryItem( + item: RegistryItem, + result: RegistryLoadResult, + rootDir: string, + fallbackDir: string +) { + const registryItem = { + ...normalizeRegistryItemFilePaths( + item, + result.itemSources, + rootDir, + fallbackDir + ), + $schema: "https://ui.shadcn.com/schema/registry-item.json", + } + + for (let index = 0; index < (registryItem.files ?? []).length; index++) { + const file = registryItem.files?.[index] + const sourceFile = item.files?.[index] + if (!file || !sourceFile) { + continue + } + + const sourcePath = getRegistryItemFileSource( + item, + sourceFile.path, + result.itemSources, + fallbackDir + ) + ;(file as typeof file & { content?: string }).content = await fs.readFile( + sourcePath, + "utf-8" + ) + } + + return registryItemSchema.parse(registryItem) +} + +export function normalizeRegistryItemFilePaths( + item: RegistryItem, + itemSources: Map, + rootDir: string, + fallbackDir: string +) { + return { + ...item, + files: item.files?.map(({ content, ...file }) => ({ + ...file, + path: getRegistryItemFileRootPath( + item, + file.path, + itemSources, + rootDir, + fallbackDir + ), + })), + } +} + +export function getRegistryItemFileSource( + item: Pick, + filePath: string, + itemSources: Map, + fallbackDir: string +) { + const source = itemSources.get(item.name) + return path.resolve(source?.registryDir ?? fallbackDir, filePath) +} + +export function getRegistryItemFileRootPath( + item: Pick, + filePath: string, + itemSources: Map, + rootDir: string, + fallbackDir: string +) { + const sourcePath = getRegistryItemFileSource( + item, + filePath, + itemSources, + fallbackDir + ) + + return path.relative(rootDir, sourcePath).split(path.sep).join("/") +} + +async function readRegistryFile( + registryFile: string, + registry: RegistryChunk, + context: { + cwd: string + itemSources: Map + itemSourcesByItem: Map + }, + chain: string[] +): Promise { + validateRegistryFileWithinRoot(registryFile, context.cwd) + + if (chain.includes(registryFile)) { + throw new Error(formatIncludeCycle([...chain, registryFile])) + } + + const nextChain = [...chain, registryFile] + const registryDir = path.dirname(registryFile) + + const includedItems: RegistryItem[] = [] + for (const includePath of registry.include ?? []) { + const includedRegistryFile = resolveIncludePath( + includePath, + registryDir, + context.cwd, + registryFile + ) + const content = await readRegistryJson(includedRegistryFile) + const parsedRegistry = parseRegistry(content, includedRegistryFile) + const includedRegistry = await readRegistryFile( + includedRegistryFile, + parsedRegistry, + context, + nextChain + ) + includedItems.push(...includedRegistry.items) + } + + registry.items.forEach((item, itemIndex) => { + validateRegistryItemFiles(item, registryFile, registryDir) + context.itemSources.set(item.name, { + registryFile, + registryDir, + itemIndex, + }) + context.itemSourcesByItem.set(item, { + registryFile, + registryDir, + itemIndex, + }) + }) + + return { + ...registry, + items: [...includedItems, ...registry.items], + } +} + +async function readRegistryJson(registryFile: string) { + try { + return await fs.readFile(registryFile, "utf-8") + } catch (error) { + throw new Error( + `Failed to read registry file at ${registryFile}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ) + } +} + +function parseRegistry(content: string, registryFile: string) { + let json: unknown + try { + json = JSON.parse(content) + } catch (error) { + throw new Error( + `Invalid JSON in registry file at ${registryFile}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ) + } + + const result = registryChunkSchema.safeParse(json) + if (!result.success) { + throw new Error( + `Invalid registry file at ${registryFile}:\n${formatZodIssues( + result.error + )}` + ) + } + + return result.data +} + +function validateRootRegistry( + registry: RegistryChunk, + registryFile: string +): asserts registry is Registry { + const missingFields = [] + + if (!registry.name) { + missingFields.push("name") + } + + if (!registry.homepage) { + missingFields.push("homepage") + } + + if (missingFields.length) { + throw new Error( + `Invalid root registry file at ${registryFile}: missing required field${missingFields.length > 1 ? "s" : ""} ${missingFields + .map((field) => `"${field}"`) + .join(", ")}.` + ) + } +} + +function resolveIncludePath( + includePath: string, + registryDir: string, + cwd: string, + registryFile: string +) { + if (isUrl(includePath)) { + throw new Error( + `Invalid include "${includePath}" in ${registryFile}: remote includes are not supported by shadcn build.` + ) + } + + if (path.isAbsolute(includePath)) { + throw new Error( + `Invalid include "${includePath}" in ${registryFile}: include paths must be relative.` + ) + } + + if (hasParentTraversal(includePath)) { + throw new Error( + `Invalid include "${includePath}" in ${registryFile}: include paths cannot use parent-directory traversal.` + ) + } + + if (path.basename(includePath) !== "registry.json") { + throw new Error( + `Invalid include "${includePath}" in ${registryFile}: include paths must explicitly reference a registry.json file.` + ) + } + + const resolvedPath = path.resolve(registryDir, includePath) + validateRegistryFileWithinRoot(resolvedPath, cwd) + + return resolvedPath +} + +function validateRegistryFileWithinRoot(registryFile: string, cwd: string) { + if (!isPathInside(registryFile, cwd)) { + throw new Error( + `Invalid registry file at ${registryFile}: registry includes must stay inside ${cwd}.` + ) + } +} + +function resolveLoadRegistryOptions(options?: LoadRegistryOptions) { + return { + cwd: path.resolve(options?.cwd ?? process.cwd()), + registryFile: options?.registryFile ?? "registry.json", + } +} + +function getRegistryRootDir( + result: Pick, + cwd: string, + registryFile: string +) { + return result.usesInclude + ? path.dirname(path.resolve(cwd, registryFile)) + : cwd +} + +function validateRegistryItemFiles( + item: RegistryItem, + registryFile: string, + registryDir: string +) { + for (const file of item.files ?? []) { + if (isUrl(file.path)) { + throw new Error( + `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: remote file paths are not supported by shadcn build.` + ) + } + + if (path.isAbsolute(file.path)) { + throw new Error( + `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: file paths must be relative.` + ) + } + + if (hasParentTraversal(file.path)) { + throw new Error( + `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: file paths cannot use parent-directory traversal.` + ) + } + + const resolvedPath = path.resolve(registryDir, file.path) + if (!isPathInside(resolvedPath, registryDir)) { + throw new Error( + `Invalid file path "${file.path}" for item "${item.name}" in ${registryFile}: file paths must stay inside the registry chunk directory.` + ) + } + } +} + +function validateDuplicateItems( + items: RegistryItem[], + itemSources: Map +) { + const seen = new Map() + + for (const item of items) { + const existing = seen.get(item.name) + if (!existing) { + seen.set(item.name, item) + continue + } + + const firstSource = itemSources.get(existing) + const secondSource = itemSources.get(item) + throw new Error( + `Duplicate registry item name "${item.name}". Registry item names must be unique.\n` + + ` - ${formatItemSource(firstSource)}\n` + + ` - ${formatItemSource(secondSource)}` + ) + } +} + +function validateRegistryDependencies( + items: RegistryItem[], + itemSources: Map +) { + const itemNames = new Set(items.map((item) => item.name)) + + for (const item of items) { + for (const dependency of item.registryDependencies ?? []) { + if ( + dependency.startsWith("@") || + isUrl(dependency) || + itemNames.has(dependency) + ) { + continue + } + + const source = itemSources.get(item.name) + console.warn( + `Warning: Registry item "${item.name}" depends on "${dependency}", but it was not found in the resolved registry catalog (${formatItemSource( + source + )}). It will be resolved externally during install.` + ) + } + } +} + +function hasParentTraversal(filePath: string) { + return filePath.split(/[\\/]+/).includes("..") +} + +function isPathInside(filePath: string, root: string) { + const relative = path.relative(root, filePath) + return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative) +} + +function formatIncludeCycle(chain: string[]) { + return `Registry include cycle detected:\n${chain + .map((file) => ` - ${file}`) + .join("\n")}` +} + +function formatItemSource(source: RegistryItemSource | undefined) { + if (!source) { + return "unknown source" + } + + return `${source.registryFile} items[${source.itemIndex}]` +} + +function formatZodIssues(error: z.ZodError) { + return error.errors + .map((issue) => { + const issuePath = issue.path.length ? issue.path.join(".") : "(root)" + return ` - ${issuePath}: ${issue.message}` + }) + .join("\n") +} diff --git a/packages/shadcn/src/registry/schema.test.ts b/packages/shadcn/src/registry/schema.test.ts index e7fa2c9b4ad..808b5d8ae9f 100644 --- a/packages/shadcn/src/registry/schema.test.ts +++ b/packages/shadcn/src/registry/schema.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" -import { registryConfigSchema } from "./schema" +import { + registryChunkSchema, + registryConfigSchema, + registrySchema, +} from "./schema" describe("registryConfigSchema", () => { it("should accept valid registry names starting with @", () => { @@ -47,3 +51,33 @@ describe("registryConfigSchema", () => { } }) }) + +describe("registrySchema", () => { + it("should accept registry chunks with includes", () => { + const result = registryChunkSchema.safeParse({ + include: ["./registry/ui/registry.json"], + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.items).toEqual([]) + } + }) + + it("should require name and homepage for root registries", () => { + const result = registrySchema.safeParse({ + include: ["./registry/ui/registry.json"], + }) + + expect(result.success).toBe(false) + }) + + it("should reject registries without items or include", () => { + const result = registryChunkSchema.safeParse({ + name: "example", + homepage: "https://example.com", + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts index 9bab2e84fe9..ff24e3f5315 100644 --- a/packages/shadcn/src/registry/schema.ts +++ b/packages/shadcn/src/registry/schema.ts @@ -198,11 +198,37 @@ export type RegistryBaseItem = Extract // Helper type for registry:font items specifically. export type RegistryFontItem = Extract -export const registrySchema = z.object({ - name: z.string(), - homepage: z.string(), - items: z.array(registryItemSchema), -}) +const registryBaseSchema = z + .object({ + $schema: z.string().optional(), + name: z.string().optional(), + homepage: z.string().optional(), + include: z.array(z.string()).optional(), + items: z.array(registryItemSchema).optional(), + }) + .refine( + (registry) => + registry.items !== undefined || registry.include !== undefined, + { + message: "Registry must define at least one of `items` or `include`.", + path: ["items"], + } + ) + +export const registryChunkSchema = registryBaseSchema.transform((registry) => ({ + ...registry, + items: registry.items ?? [], +})) + +export const registrySchema = registryChunkSchema.pipe( + z.object({ + $schema: z.string().optional(), + name: z.string(), + homepage: z.string(), + include: z.array(z.string()).optional(), + items: z.array(registryItemSchema), + }) +) export type Registry = z.infer From 76a1677c1beb017bc4a00f5f63a5514e66bb90b6 Mon Sep 17 00:00:00 2001 From: shadcn Date: Wed, 20 May 2026 14:12:58 +0400 Subject: [PATCH 2/7] feat: updates --- .../content/docs/(root)/{v0.mdx => _v0.mdx} | 0 apps/v4/content/docs/(root)/meta.json | 1 - .../changelog/2026-05-registry-include.mdx | 99 ++++ .../content/docs/registry/getting-started.mdx | 527 +++++++++++++++--- apps/v4/content/docs/registry/meta.json | 4 +- .../content/docs/registry/registry-index.mdx | 4 +- .../content/docs/registry/registry-json.mdx | 78 ++- apps/v4/lib/docs.ts | 4 +- packages/shadcn/src/registry/errors.ts | 80 ++- packages/shadcn/src/registry/index.ts | 3 + packages/shadcn/src/registry/loader.test.ts | 84 ++- packages/shadcn/src/registry/loader.ts | 179 ++++-- 12 files changed, 917 insertions(+), 146 deletions(-) rename apps/v4/content/docs/(root)/{v0.mdx => _v0.mdx} (100%) create mode 100644 apps/v4/content/docs/changelog/2026-05-registry-include.mdx diff --git a/apps/v4/content/docs/(root)/v0.mdx b/apps/v4/content/docs/(root)/_v0.mdx similarity index 100% rename from apps/v4/content/docs/(root)/v0.mdx rename to apps/v4/content/docs/(root)/_v0.mdx diff --git a/apps/v4/content/docs/(root)/meta.json b/apps/v4/content/docs/(root)/meta.json index a1ad4895976..ee767a33b39 100644 --- a/apps/v4/content/docs/(root)/meta.json +++ b/apps/v4/content/docs/(root)/meta.json @@ -11,7 +11,6 @@ "[CLI](/docs/cli)", "monorepo", "skills", - "v0", "javascript", "blocks", "figma", diff --git a/apps/v4/content/docs/changelog/2026-05-registry-include.mdx b/apps/v4/content/docs/changelog/2026-05-registry-include.mdx new file mode 100644 index 00000000000..707d70d326d --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-05-registry-include.mdx @@ -0,0 +1,99 @@ +--- +title: May 2026 - Registry Includes +description: Organize large registries with included registry.json files. +date: 2026-05-20 +--- + +We've added support for `include` in `registry.json`. + +Registry authors can now organize a large source registry across multiple +`registry.json` files and compose them with `shadcn build`. + +```txt +registry.json +components +└── ui + ├── button.tsx + ├── input.tsx + └── registry.json +hooks +├── registry.json +├── use-media-query.ts +└── use-toggle.ts +``` + +{/* prettier-ignore */} +```json title="registry.json" showLineNumbers +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "acme", + "homepage": "https://acme.com", + "include": [ + "components/ui/registry.json", + "hooks/registry.json" + ] +} +``` + +Each included file is a regular `registry.json`, so it can also be opened, +validated, and built on its own. + +```json title="components/ui/registry.json" showLineNumbers +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "button", + "type": "registry:ui", + "files": [ + { + "path": "button.tsx", + "type": "registry:ui" + } + ] + } + ] +} +``` + +## Build output + +`shadcn build` resolves included registries and writes a flattened +`registry.json` without `include`. Item file paths are preserved from the root +registry, so a file declared in `components/ui/registry.json` is written as +`components/ui/button.tsx` in the built registry item. + +## Registry loaders + +The `shadcn/registry` package also exports `loadRegistry` and +`loadRegistryItem` for dynamic registry routes. + +```ts title="app/r/registry.json/route.ts" showLineNumbers +import { NextResponse } from "next/server" +import { loadRegistry } from "shadcn/registry" + +export async function GET() { + const registry = await loadRegistry() + + return NextResponse.json(registry) +} +``` + +```ts title="app/r/[name].json/route.ts" showLineNumbers +import { NextResponse } from "next/server" +import { loadRegistryItem } from "shadcn/registry" + +export async function GET( + _: Request, + { params }: { params: Promise<{ name: string }> } +) { + const { name } = await params + const item = await loadRegistryItem(name) + + return NextResponse.json(item) +} +``` + +See the [registry.json documentation](/docs/registry/registry-json#include) and +[getting started guide](/docs/registry/getting-started#structure-your-registry) +for more details. diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx index 2b96384a819..02fa0455d70 100644 --- a/apps/v4/content/docs/registry/getting-started.mdx +++ b/apps/v4/content/docs/registry/getting-started.mdx @@ -9,7 +9,9 @@ If you're starting a new registry project, you can use the [registry template](h ## Requirements -You are free to design and host your custom registry as you see fit. The only requirement is that your registry items must be valid JSON files that conform to the [registry-item schema specification](/docs/registry/registry-item-json). +You are free to design and host your custom registry as you see fit. The only requirement is that your registry catalog and registry items must be valid JSON files that conform to the [registry schema specification](/docs/registry/registry-json) and [registry-item schema specification](/docs/registry/registry-item-json). + +Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP. If you'd like to see an example of a registry, we have a [template project](https://github.com/shadcn-ui/registry-template) for you to use as a starting point. @@ -19,11 +21,40 @@ The `registry.json` is the entry point for the registry. It contains the registr Your registry must have this file (or JSON payload) present at the root of the registry endpoint. The registry endpoint is the URL where your registry is hosted. -The `shadcn` CLI will automatically generate this file for you when you run the `build` command. +Here's an example `registry.json` file: + +```json title="registry.json" showLineNumbers +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "acme", + "homepage": "https://acme.com", + "items": [ + { + "name": "button", + "type": "registry:ui", + "title": "Button", + "description": "A simple button component.", + "files": [ + { + "path": "components/ui/button.tsx", + "type": "registry:ui" + } + ] + } + ] +} +``` + +## Structure your registry + +You can structure your source registry in one of two ways: + +- Define all items in a single root `registry.json`. +- Use a root `registry.json` with `include` to compose multiple `registry.json` files. -## Add a registry.json file +### Option A: Single registry.json -Create a `registry.json` file in the root of your project. Your project can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP. +Create a `registry.json` file in the root of your project. Add all your registry items to the `items` array. This is the simplest way to define a registry. ```json title="registry.json" showLineNumbers { @@ -31,44 +62,171 @@ Create a `registry.json` file in the root of your project. Your project can be a "name": "acme", "homepage": "https://acme.com", "items": [ - // ... + { + "name": "button", + "type": "registry:ui", + "title": "Button", + "description": "A simple button component.", + "files": [ + { + "path": "components/ui/button.tsx", + "type": "registry:ui" + } + ] + }, + { + "name": "hello-world", + "type": "registry:block", + "title": "Hello World", + "description": "A simple hello world component.", + "registryDependencies": ["button"], + "files": [ + { + "path": "registry/default/hello-world/hello-world.tsx", + "type": "registry:component" + } + ] + } ] } ``` This `registry.json` file must conform to the [registry schema specification](/docs/registry/registry-json). -## Add a registry item +### Option B: Using include -### Create your component +For larger registries, you can use `include` to compose your source registry +from multiple `registry.json` files. -Add your first component. Here's an example of a simple `` component: +```txt +registry.json +components +└── ui + ├── button.tsx + ├── input.tsx + └── registry.json +hooks +├── registry.json +├── use-media-query.ts +└── use-toggle.ts +``` -```tsx title="registry/new-york/hello-world/hello-world.tsx" showLineNumbers -import { Button } from "@/components/ui/button" +The root `registry.json` defines the registry metadata and includes the nested +registry files. -export function HelloWorld() { - return +{/* prettier-ignore */} +```json title="registry.json" showLineNumbers +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "acme", + "homepage": "https://acme.com", + "include": [ + "components/ui/registry.json", + "hooks/registry.json" + ] +} +``` + +Included `registry.json` files are valid registry files for composition and may +omit `name` and `homepage`. Only the root `registry.json` must define the +registry metadata. + +```json title="components/ui/registry.json" showLineNumbers +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "button", + "type": "registry:ui", + "files": [ + { + "path": "button.tsx", + "type": "registry:ui" + } + ] + }, + { + "name": "input", + "type": "registry:ui", + "files": [ + { + "path": "input.tsx", + "type": "registry:ui" + } + ] + } + ] +} +``` + +```json title="hooks/registry.json" showLineNumbers +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "use-toggle", + "type": "registry:hook", + "files": [ + { + "path": "use-toggle.ts", + "type": "registry:hook" + } + ] + }, + { + "name": "use-media-query", + "type": "registry:hook", + "files": [ + { + "path": "use-media-query.ts", + "type": "registry:hook" + } + ] + } + ] +} +``` + +When using `include`, file paths are relative to the `registry.json` file that +declares the item. + +## Add an item + +### Create a UI component + +Add your first item. Here's an example of a simple `