diff --git a/.changeset/icy-birds-warn.md b/.changeset/icy-birds-warn.md new file mode 100644 index 000000000..a16358da2 --- /dev/null +++ b/.changeset/icy-birds-warn.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add shadcn registry validate command diff --git a/packages/shadcn/src/commands/registry/index.ts b/packages/shadcn/src/commands/registry/index.ts index 2b3c1aaa2..7715cf830 100644 --- a/packages/shadcn/src/commands/registry/index.ts +++ b/packages/shadcn/src/commands/registry/index.ts @@ -1,7 +1,9 @@ import { add } from "@/src/commands/registry/add" +import { validate } from "@/src/commands/registry/validate" import { Command } from "commander" export const registry = new Command() .name("registry") .description("manage registries") .addCommand(add) + .addCommand(validate) diff --git a/packages/shadcn/src/commands/registry/validate.test.ts b/packages/shadcn/src/commands/registry/validate.test.ts new file mode 100644 index 000000000..4ec685eb6 --- /dev/null +++ b/packages/shadcn/src/commands/registry/validate.test.ts @@ -0,0 +1,137 @@ +import * as fs from "fs/promises" +import { tmpdir } from "os" +import * as path from "path" +import { logger } from "@/src/utils/logger" +import { spinner } from "@/src/utils/spinner" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { validate } from "./validate" + +vi.mock("@/src/utils/handle-error", () => ({ + handleError: vi.fn((error) => { + throw error + }), +})) + +vi.mock("@/src/utils/highlighter", () => ({ + highlighter: { + error: (value: string) => value, + info: (value: string) => value, + success: (value: string) => value, + }, +})) + +vi.mock("@/src/utils/logger", () => ({ + logger: { + break: vi.fn(), + error: vi.fn(), + log: vi.fn(), + }, +})) + +vi.mock("@/src/utils/spinner", () => ({ + spinner: vi.fn(() => ({ + fail: vi.fn(), + start: vi.fn().mockReturnThis(), + succeed: vi.fn(), + })), +})) + +describe("registry validate command", () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + }) + + it("prints success with checked counts", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [], + }), + }) + + await validate.parseAsync(["registry.json", "--cwd", cwd], { + from: "user", + }) + + const validationSpinner = vi.mocked(spinner).mock.results[0].value + const summarySpinner = vi.mocked(spinner).mock.results[1].value + expect(validationSpinner.succeed).toHaveBeenCalledWith("Registry is valid.") + expect(spinner).toHaveBeenCalledWith("Checked 1 registry file and 0 items.") + expect(summarySpinner.succeed).toHaveBeenCalled() + expect(logger.log).toHaveBeenCalledWith(" - registry.json") + expect( + vi.mocked(logger.log).mock.calls.map(([message]) => message) + ).toEqual([" - registry.json"]) + expect(process.exitCode).toBeUndefined() + }) + + it("prints grouped diagnostics and sets a failing exit code", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["components/ui/registry.json"], + }), + "components/ui/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "missing.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + }) + + await validate.parseAsync(["registry.json", "--cwd", cwd], { + from: "user", + }) + + const validationSpinner = vi.mocked(spinner).mock.results[0].value + expect(validationSpinner.fail).toHaveBeenCalledWith( + "Registry validation failed." + ) + expect(spinner).toHaveBeenCalledTimes(1) + expect(logger.log).toHaveBeenCalledWith( + " Checked 2 registry files and 1 item." + ) + expect(logger.log).toHaveBeenCalledWith(" - registry.json") + expect(logger.log).toHaveBeenCalledWith(" - components/ui/registry.json") + expect(logger.log).toHaveBeenCalledWith("components/ui/registry.json") + expect(logger.error).toHaveBeenCalledWith( + ' - items[0] "button" file "missing.tsx": File "missing.tsx" was not found or could not be read.' + ) + expect( + vi.mocked(logger.log).mock.calls.map(([message]) => message) + ).toEqual([ + " Checked 2 registry files and 1 item.", + " - registry.json", + " - components/ui/registry.json", + "components/ui/registry.json", + " Make sure the file path is relative to the registry.json file that declares the item.", + ]) + expect(process.exitCode).toBe(1) + }) +}) + +async function createFixture(files: Record) { + const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-command-")) + + 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/commands/registry/validate.ts b/packages/shadcn/src/commands/registry/validate.ts new file mode 100644 index 000000000..27e0305e4 --- /dev/null +++ b/packages/shadcn/src/commands/registry/validate.ts @@ -0,0 +1,168 @@ +import * as path from "path" +import { validateRegistry } from "@/src/registry/validate" +import { handleError } from "@/src/utils/handle-error" +import { highlighter } from "@/src/utils/highlighter" +import { logger } from "@/src/utils/logger" +import { spinner } from "@/src/utils/spinner" +import { Command } from "commander" +import { z } from "zod" + +const validateOptionsSchema = z.object({ + cwd: z.string(), + registryFile: z.string(), +}) + +export const validate = new Command() + .name("validate") + .description("validate a shadcn registry") + .argument("[registry]", "path to registry.json file", "./registry.json") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd() + ) + .action(async (registryFile: string, opts) => { + let validationSpinner: ReturnType | undefined + + try { + const options = validateOptionsSchema.parse({ + cwd: path.resolve(opts.cwd), + registryFile, + }) + validationSpinner = spinner("Validating registry.").start() + const report = await validateRegistry(options) + + printRegistryValidationReport(report, validationSpinner) + + if (!report.valid) { + process.exitCode = 1 + } + } catch (error) { + validationSpinner?.fail("Registry validation failed.") + logger.break() + handleError(error) + } + }) + +function printRegistryValidationReport( + report: Awaited>, + validationSpinner: ReturnType +) { + if (report.valid) { + validationSpinner.succeed("Registry is valid.") + printRegistryValidationStats(report, { success: true }) + return + } + + validationSpinner.fail("Registry validation failed.") + printRegistryValidationStats(report) + logger.break() + + for (const [registryFile, diagnostics] of Array.from( + groupDiagnostics(report) + )) { + logger.log(highlighter.info(formatPath(registryFile, report.cwd))) + + for (const diagnostic of diagnostics) { + logger.error(` - ${formatDiagnostic(diagnostic)}`) + if (diagnostic.suggestion) { + logger.log(` ${diagnostic.suggestion}`) + } + } + + logger.break() + } +} + +function printRegistryValidationStats( + report: Awaited>, + options: { + success?: boolean + } = {} +) { + const message = `Checked ${formatCount( + report.registryFiles, + "registry file", + "registry files" + )} and ${formatCount(report.items, "item", "items")}.` + + if (options.success) { + printSuccess(message) + } else { + logger.log(` ${message}`) + } + + for (const registryFile of report.registryFilePaths) { + logger.log(` - ${formatPath(registryFile, report.cwd)}`) + } +} + +function groupDiagnostics( + report: Awaited> +) { + const groups = new Map() + + for (const diagnostic of report.diagnostics) { + const diagnostics = groups.get(diagnostic.registryFile) ?? [] + diagnostics.push(diagnostic) + groups.set(diagnostic.registryFile, diagnostics) + } + + return groups +} + +function formatDiagnostic( + diagnostic: Awaited< + ReturnType + >["diagnostics"][number] +) { + const context = [] + + if (diagnostic.itemIndex !== undefined) { + context.push(`items[${diagnostic.itemIndex}]`) + } + + if (diagnostic.itemName) { + context.push(`"${diagnostic.itemName}"`) + } + + if (diagnostic.includePath) { + context.push(`include "${diagnostic.includePath}"`) + } + + if (diagnostic.filePath) { + context.push(`file "${diagnostic.filePath}"`) + } + + if (!context.length) { + return diagnostic.message + } + + return `${context.join(" ")}: ${diagnostic.message}` +} + +function formatPath(filePath: string, cwd: string) { + const relativePath = path.relative(cwd, filePath) + + if ( + relativePath && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) + ) { + return relativePath.split(path.sep).join("/") + } + + if (!relativePath) { + return "." + } + + return filePath +} + +function printSuccess(message: string) { + spinner(message).succeed() +} + +function formatCount(count: number, singular: string, plural: string) { + return `${count} ${count === 1 ? singular : plural}` +} diff --git a/packages/shadcn/src/registry/validate.test.ts b/packages/shadcn/src/registry/validate.test.ts new file mode 100644 index 000000000..0f549e674 --- /dev/null +++ b/packages/shadcn/src/registry/validate.test.ts @@ -0,0 +1,594 @@ +import * as fs from "fs/promises" +import { tmpdir } from "os" +import * as path from "path" +import { describe, expect, it } from "vitest" + +import { validateRegistry } from "./validate" + +describe("validateRegistry", () => { + it("validates a buildable source registry with include", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["components/ui/registry.json"], + }), + "components/ui/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:ui", + registryDependencies: [ + "input", + "@acme/dialog", + "https://example.com/r/card.json", + ], + files: [ + { + path: "button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + "components/ui/button.tsx": "export function Button() {}", + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(true) + expect(report.registryFiles).toBe(2) + expect( + report.registryFilePaths.map((filePath) => path.relative(cwd, filePath)) + ).toEqual(["registry.json", path.join("components", "ui", "registry.json")]) + expect(report.items).toBe(1) + expect(report.diagnostics).toEqual([]) + }) + + it("validates an empty source registry", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(true) + expect(report.registryFiles).toBe(1) + expect(report.items).toBe(0) + }) + + it("preserves cwd-relative files for legacy single-file registries", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + "button.tsx": "export function Button() {}", + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(true) + expect(report.diagnostics).toEqual([]) + }) + + it("collects independent diagnostics across include branches", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["components/ui.json", "hooks/registry.json"], + items: [ + { + name: "button", + type: "registry:ui", + }, + ], + }), + "hooks/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:hook", + files: [ + { + path: "missing.ts", + type: "registry:hook", + }, + ], + }, + ], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual( + expect.arrayContaining([ + 'Include "components/ui.json" must explicitly reference a registry.json file.', + expect.stringContaining('Duplicate registry item name "button"'), + 'File "missing.ts" was not found or could not be read.', + ]) + ) + }) + + it("continues validating valid items when another item is invalid", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "missing.tsx", + type: "registry:ui", + }, + ], + }, + { + name: "brand-font", + type: "registry:font", + }, + ], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.items).toBe(2) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + itemIndex: 0, + itemName: "button", + message: 'File "missing.tsx" was not found or could not be read.', + }), + expect.objectContaining({ + itemIndex: 1, + itemName: "brand-font", + message: "font: Required", + }), + ]) + ) + }) + + it("reports all root-level issues for an empty object", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({}), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual( + expect.arrayContaining([ + 'Root registry.json must define "name".', + 'Root registry.json must define "homepage".', + "Registry must define at least one of `items` or `include`.", + ]) + ) + }) + + it("filters internal registry item types from item type diagnostics", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "button", + type: "registry:unknown", + }, + ], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("Invalid registry item type"), + }), + ]) + ) + expect( + report.diagnostics.some( + (diagnostic) => + diagnostic.message.includes("registry:example") || + diagnostic.message.includes("registry:internal") + ) + ).toBe(false) + }) + + it("requires the root registry file to be named registry.json", async () => { + const cwd = await createFixture({ + "registry.flat.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.flat.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Root source registry file must be named registry.json.", + }), + ]) + ) + }) + + it("reports missing root registry files as validation diagnostics", async () => { + const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-")) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.registryFiles).toBe(1) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Registry file was not found or could not be read.", + }), + ]) + ) + }) + + it("reports include cycles", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["registry.json"], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("Registry include cycle detected"), + }), + ]) + ) + }) + + it("reports include paths that are remote, absolute, or parent-traversing", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: [ + "https://example.com/registry.json", + path.join(cwdRoot(), "registry.json"), + "../registry.json", + ], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual( + expect.arrayContaining([ + 'Remote include "https://example.com/registry.json" is not supported.', + expect.stringContaining("must be relative"), + 'Include "../registry.json" cannot use parent-directory traversal.', + ]) + ) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + includePath: "../registry.json", + suggestion: + "Registry includes must descend from the including chunk. Move shared registries into the registry root and include them from there.", + }), + ]) + ) + }) + + it("reports root registry files outside cwd", async () => { + const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-")) + const outside = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: path.relative(cwd, path.join(outside, "registry.json")), + }) + + expect(report.valid).toBe(false) + expect(report.registryFiles).toBe(0) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + "Root registry file must stay inside" + ), + }), + ]) + ) + }) + + it("reports include trees that are too deep", async () => { + const files: Record = {} + const depth = 33 + + for (let index = 0; index <= depth; index++) { + const filePath = + index === 0 + ? "registry.json" + : path.join(...getIncludeSegments(index), "registry.json") + const nextPath = + index === depth + ? undefined + : path.join(...getIncludeSegments(index + 1), "registry.json") + + files[filePath] = JSON.stringify({ + ...(index === 0 + ? { + name: "example", + homepage: "https://example.com", + } + : {}), + ...(nextPath + ? { include: [path.relative(path.dirname(filePath), nextPath)] } + : { items: [] }), + }) + } + + const cwd = await createFixture(files) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("Registry include tree is too deep"), + }), + ]) + ) + }) + + it("reports registry files included through multiple branches", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: [ + "components/registry.json", + "components/shared/registry.json", + ], + }), + "components/registry.json": JSON.stringify({ + include: ["shared/registry.json"], + }), + "components/shared/registry.json": JSON.stringify({ + items: [], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.registryFiles).toBe(3) + expect( + report.registryFilePaths.map((filePath) => path.relative(cwd, filePath)) + ).toEqual([ + "registry.json", + path.join("components", "registry.json"), + path.join("components", "shared", "registry.json"), + ]) + expect(report.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + "Registry file included more than once" + ), + }), + ]) + ) + }) + + it("reports missing root registry metadata", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + items: [], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual( + expect.arrayContaining([ + 'Root registry.json must define "name".', + 'Root registry.json must define "homepage".', + ]) + ) + }) + + it("reports invalid JSON and missing includes without validating dependency names", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["components/ui/registry.json", "hooks/registry.json"], + items: [ + { + name: "card", + type: "registry:ui", + registryDependencies: ["input"], + }, + ], + }), + "components/ui/registry.json": "{", + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.registryFiles).toBe(3) + expect( + report.registryFilePaths.map((filePath) => path.relative(cwd, filePath)) + ).toEqual([ + "registry.json", + path.join("components", "ui", "registry.json"), + path.join("hooks", "registry.json"), + ]) + expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual( + expect.arrayContaining([ + "Registry file contains invalid JSON.", + "Registry file was not found or could not be read.", + ]) + ) + expect( + report.diagnostics.some((diagnostic) => + diagnostic.message.includes("input") + ) + ).toBe(false) + }) + + it("reports remote and parent-traversing item file paths", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + include: ["components/ui/registry.json"], + }), + "components/ui/registry.json": JSON.stringify({ + items: [ + { + name: "button", + type: "registry:ui", + files: [ + { + path: "https://example.com/button.tsx", + type: "registry:ui", + }, + { + path: "../shared/button.tsx", + type: "registry:ui", + }, + ], + }, + ], + }), + }) + + const report = await validateRegistry({ + cwd, + registryFile: "registry.json", + }) + + expect(report.valid).toBe(false) + expect(report.diagnostics.map((diagnostic) => diagnostic.message)).toEqual( + expect.arrayContaining([ + 'File path "https://example.com/button.tsx" cannot be remote.', + 'File path "../shared/button.tsx" cannot use parent-directory traversal.', + ]) + ) + }) +}) + +async function createFixture(files: Record) { + const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-")) + + 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 +} + +function cwdRoot() { + return path.parse(process.cwd()).root +} + +function getIncludeSegments(depth: number) { + return Array.from({ length: depth }, (_, index) => `level-${index + 1}`) +} diff --git a/packages/shadcn/src/registry/validate.ts b/packages/shadcn/src/registry/validate.ts new file mode 100644 index 000000000..4a9e300ed --- /dev/null +++ b/packages/shadcn/src/registry/validate.ts @@ -0,0 +1,723 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { isUrl } from "@/src/registry/utils" +import { + registryItemSchema, + registryItemTypeSchema, + type RegistryItem, +} from "@/src/schema" +import { z } from "zod" + +type RegistryChunk = { + $schema?: string + name?: string + homepage?: string + hasName?: boolean + hasHomepage?: boolean + include?: string[] + items: RegistryItem[] +} + +type RegistryItemSource = { + registryFile: string + registryDir: string + itemIndex: number +} + +type RegistryValidationDiagnostic = { + registryFile: string + message: string + suggestion?: string + itemName?: string + itemIndex?: number + includePath?: string + filePath?: string +} + +type RegistryValidationContext = { + cwd: string + rootFile: string + usesInclude: boolean + diagnostics: RegistryValidationDiagnostic[] + registryFiles: Set + checkedRegistryFiles: Set + itemsChecked: number + itemSourcesByItem: Map + firstIncludedFrom: Map +} + +const MAX_INCLUDE_DEPTH = 32 +const PUBLIC_REGISTRY_ITEM_TYPES = registryItemTypeSchema.options.filter( + (type) => type !== "registry:example" && type !== "registry:internal" +) +const registryObjectSchema = z.record(z.string(), z.unknown()) +const registryIncludeSchema = z.array(z.string()) +const registryItemsSchema = z.array(z.unknown()) + +export async function validateRegistry(options: { + cwd: string + registryFile: string +}) { + const cwd = path.resolve(options.cwd) + const rootFile = path.resolve(cwd, options.registryFile) + const context: RegistryValidationContext = { + cwd, + rootFile, + usesInclude: false, + diagnostics: [], + registryFiles: new Set(), + checkedRegistryFiles: new Set(), + itemsChecked: 0, + itemSourcesByItem: new Map(), + firstIncludedFrom: new Map(), + } + + if (path.basename(rootFile) !== "registry.json") { + addDiagnostic(context, { + registryFile: rootFile, + message: "Root source registry file must be named registry.json.", + suggestion: + "Rename the file to registry.json and pass that file to shadcn registry validate.", + }) + } + + if (!isPathInside(rootFile, cwd)) { + addDiagnostic(context, { + registryFile: rootFile, + message: `Root registry file must stay inside ${formatPath(cwd, cwd)}.`, + suggestion: + "Run the command from the registry root or pass a registry.json file inside --cwd.", + }) + return createValidationResult(context, []) + } + + const rootRegistry = await readRegistryFile(rootFile, context) + if (!rootRegistry) { + return createValidationResult(context, []) + } + + context.usesInclude = !!rootRegistry.include?.length + validateRootRegistry(rootRegistry, rootFile, context) + + const items = await collectRegistryItems(rootFile, rootRegistry, context, []) + + validateDuplicateItems(items, context) + await validateRegistryItems(items, context) + + return createValidationResult(context, items) +} + +async function collectRegistryItems( + registryFile: string, + registry: RegistryChunk, + context: RegistryValidationContext, + chain: string[] +): Promise { + if (chain.length >= MAX_INCLUDE_DEPTH) { + addDiagnostic(context, { + registryFile, + message: `Registry include tree is too deep. The maximum include depth is ${MAX_INCLUDE_DEPTH}.`, + suggestion: + "Flatten part of the registry include tree or reduce nested include depth.", + }) + return [] + } + + if (chain.includes(registryFile)) { + addDiagnostic(context, { + registryFile, + message: `Registry include cycle detected: ${formatIncludeCycle([ + ...chain, + registryFile, + ])}.`, + suggestion: "Remove one include so the registry graph is acyclic.", + }) + return [] + } + + const includedFrom = chain.at(-1) ?? registryFile + const existingSource = context.firstIncludedFrom.get(registryFile) + if (existingSource) { + addDiagnostic(context, { + registryFile, + message: `Registry file included more than once. First included from ${formatPath( + existingSource, + context.cwd + )}, then included from ${formatPath(includedFrom, context.cwd)}.`, + suggestion: + "Remove one include or move shared items into a single included registry.json.", + }) + return [] + } + + context.registryFiles.add(registryFile) + context.firstIncludedFrom.set(registryFile, includedFrom) + + const registryDir = path.dirname(registryFile) + const nextChain = [...chain, registryFile] + const includedItems: RegistryItem[] = [] + + for (const includePath of registry.include ?? []) { + const includedRegistryFile = resolveIncludePath( + includePath, + registryFile, + registryDir, + context + ) + if (!includedRegistryFile) { + continue + } + + const includedRegistry = await readRegistryFile( + includedRegistryFile, + context + ) + if (!includedRegistry) { + continue + } + + const items = await collectRegistryItems( + includedRegistryFile, + includedRegistry, + context, + nextChain + ) + includedItems.push(...items) + } + + const itemRegistryDir = + // Preserve legacy single-file registry behavior: item files resolve from cwd. + !context.usesInclude && registryFile === context.rootFile + ? context.cwd + : registryDir + + registry.items.forEach((item, itemIndex) => { + context.itemSourcesByItem.set(item, { + registryFile, + registryDir: itemRegistryDir, + itemIndex, + }) + }) + + return [...includedItems, ...registry.items] +} + +async function readRegistryFile( + registryFile: string, + context: RegistryValidationContext +) { + context.checkedRegistryFiles.add(registryFile) + + let content: string + try { + content = await fs.readFile(registryFile, "utf-8") + } catch { + addDiagnostic(context, { + registryFile, + message: "Registry file was not found or could not be read.", + suggestion: "Check that the registry.json file exists and is readable.", + }) + return null + } + + let json: unknown + try { + json = JSON.parse(content) + } catch { + addDiagnostic(context, { + registryFile, + message: "Registry file contains invalid JSON.", + suggestion: "Fix the JSON syntax in the registry.json file.", + }) + return null + } + + return parseRegistryJson(json, registryFile, context) +} + +function validateRootRegistry( + registry: RegistryChunk, + registryFile: string, + context: RegistryValidationContext +) { + if (!registry.name && !registry.hasName) { + addDiagnostic(context, { + registryFile, + message: 'Root registry.json must define "name".', + suggestion: 'Add a top-level "name" field to the root registry.json.', + }) + } + + if (!registry.homepage && !registry.hasHomepage) { + addDiagnostic(context, { + registryFile, + message: 'Root registry.json must define "homepage".', + suggestion: 'Add a top-level "homepage" field to the root registry.json.', + }) + } +} + +function resolveIncludePath( + includePath: string, + registryFile: string, + registryDir: string, + context: RegistryValidationContext +) { + if (isUrl(includePath)) { + addDiagnostic(context, { + registryFile, + includePath, + message: `Remote include "${includePath}" is not supported.`, + suggestion: + "Use a relative path to an explicit registry.json file in the same repository.", + }) + return null + } + + if (path.isAbsolute(includePath)) { + addDiagnostic(context, { + registryFile, + includePath, + message: `Include "${includePath}" must be relative.`, + suggestion: 'Use a path like "components/ui/registry.json".', + }) + return null + } + + if (hasParentTraversal(includePath)) { + addDiagnostic(context, { + registryFile, + includePath, + message: `Include "${includePath}" cannot use parent-directory traversal.`, + suggestion: + "Registry includes must descend from the including chunk. Move shared registries into the registry root and include them from there.", + }) + return null + } + + if (path.basename(includePath) !== "registry.json") { + addDiagnostic(context, { + registryFile, + includePath, + message: `Include "${includePath}" must explicitly reference a registry.json file.`, + suggestion: 'Use a path like "components/ui/registry.json".', + }) + return null + } + + const resolvedPath = path.resolve(registryDir, includePath) + if (!isPathInside(resolvedPath, context.cwd)) { + addDiagnostic(context, { + registryFile, + includePath, + message: `Include "${includePath}" must stay inside ${formatPath( + context.cwd, + context.cwd + )}.`, + suggestion: "Keep included registry.json files inside the registry root.", + }) + return null + } + + return resolvedPath +} + +function validateDuplicateItems( + items: RegistryItem[], + context: RegistryValidationContext +) { + 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 = context.itemSourcesByItem.get(existing) + const secondSource = context.itemSourcesByItem.get(item) + addDiagnostic(context, { + registryFile: secondSource?.registryFile ?? context.rootFile, + itemName: item.name, + itemIndex: secondSource?.itemIndex, + message: `Duplicate registry item name "${item.name}". First defined at ${formatItemSource( + firstSource, + context.cwd + )}.`, + suggestion: + "Rename one of these items so each name is unique across the resolved registry.", + }) + } +} + +async function validateRegistryItems( + items: RegistryItem[], + context: RegistryValidationContext +) { + const registryRootDir = getRegistryRootDir(context) + + for (const item of items) { + const source = context.itemSourcesByItem.get(item) + const registryItem = { + ...rewriteRegistryItemFilePaths(item, context, registryRootDir), + $schema: "https://ui.shadcn.com/schema/registry-item.json", + } + + for (let index = 0; index < (item.files?.length ?? 0); index++) { + const file = item.files?.[index] + if (!file || !source) { + continue + } + + const sourcePath = validateRegistryItemFilePath( + item, + file.path, + source, + context + ) + if (!sourcePath) { + continue + } + + try { + await fs.readFile(sourcePath, "utf-8") + } catch { + addDiagnostic(context, { + registryFile: source.registryFile, + itemName: item.name, + itemIndex: source.itemIndex, + filePath: file.path, + message: `File "${file.path}" was not found or could not be read.`, + suggestion: + "Make sure the file path is relative to the registry.json file that declares the item.", + }) + } + } + + const result = registryItemSchema.safeParse(registryItem) + if (!result.success) { + addZodDiagnostics( + result.error, + source?.registryFile ?? context.rootFile, + context, + { + itemName: item.name, + itemIndex: source?.itemIndex, + suggestion: + "Update the registry item so the built item matches the registry item schema.", + } + ) + } + } +} + +function validateRegistryItemFilePath( + item: RegistryItem, + filePath: string, + source: RegistryItemSource, + context: RegistryValidationContext +) { + if (isUrl(filePath)) { + addDiagnostic(context, { + registryFile: source.registryFile, + itemName: item.name, + itemIndex: source.itemIndex, + filePath, + message: `File path "${filePath}" cannot be remote.`, + suggestion: + "Use a local file path relative to the registry.json file that declares the item.", + }) + return null + } + + if (path.isAbsolute(filePath)) { + addDiagnostic(context, { + registryFile: source.registryFile, + itemName: item.name, + itemIndex: source.itemIndex, + filePath, + message: `File path "${filePath}" must be relative.`, + suggestion: + "Use a local file path relative to the registry.json file that declares the item.", + }) + return null + } + + if (hasParentTraversal(filePath)) { + addDiagnostic(context, { + registryFile: source.registryFile, + itemName: item.name, + itemIndex: source.itemIndex, + filePath, + message: `File path "${filePath}" cannot use parent-directory traversal.`, + suggestion: "Keep item files inside the registry chunk directory.", + }) + return null + } + + const sourcePath = path.resolve(source.registryDir, filePath) + if (!isPathInside(sourcePath, source.registryDir)) { + addDiagnostic(context, { + registryFile: source.registryFile, + itemName: item.name, + itemIndex: source.itemIndex, + filePath, + message: `File path "${filePath}" must stay inside the registry chunk directory.`, + suggestion: + "Move the file into the same registry chunk directory or update the registry item path.", + }) + return null + } + + return sourcePath +} + +function rewriteRegistryItemFilePaths( + item: RegistryItem, + context: RegistryValidationContext, + rootDir: string +) { + const source = context.itemSourcesByItem.get(item) + + return { + ...item, + files: item.files?.map((file) => { + const sourcePath = path.resolve( + source?.registryDir ?? context.cwd, + file.path + ) + + return { + ...file, + path: path.relative(rootDir, sourcePath).split(path.sep).join("/"), + } + }), + } +} + +function parseRegistryJson( + json: unknown, + registryFile: string, + context: RegistryValidationContext +) { + const registryResult = registryObjectSchema.safeParse(json) + if (!registryResult.success) { + addZodDiagnostics(registryResult.error, registryFile, context, { + suggestion: "Update the registry.json file so it matches the schema.", + }) + return null + } + + const registry = registryResult.data + const chunk: RegistryChunk = { + $schema: getOptionalString(registry, "$schema", registryFile, context), + name: getOptionalString(registry, "name", registryFile, context), + homepage: getOptionalString(registry, "homepage", registryFile, context), + hasName: registry.name !== undefined, + hasHomepage: registry.homepage !== undefined, + items: [], + } + + if (registry.include !== undefined) { + const result = registryIncludeSchema.safeParse(registry.include) + if (!result.success) { + addZodDiagnostics(result.error, registryFile, context, { + pathPrefix: ["include"], + suggestion: "Update include so it is an array of registry.json paths.", + }) + } else { + chunk.include = result.data + } + } + + if (registry.items !== undefined) { + const result = registryItemsSchema.safeParse(registry.items) + if (!result.success) { + addZodDiagnostics(result.error, registryFile, context, { + pathPrefix: ["items"], + suggestion: "Update items so it is an array of registry items.", + }) + } else { + context.itemsChecked += result.data.length + chunk.items = parseRegistryItems(result.data, registryFile, context) + } + } + + if (registry.items === undefined && registry.include === undefined) { + addDiagnostic(context, { + registryFile, + message: "Registry must define at least one of `items` or `include`.", + suggestion: + 'Add an "items" array, an "include" array, or both to registry.json.', + }) + } + + return chunk +} + +function parseRegistryItems( + items: unknown[], + registryFile: string, + context: RegistryValidationContext +) { + const registryItems: RegistryItem[] = [] + + items.forEach((item, itemIndex) => { + const result = registryItemSchema.safeParse(item) + if (!result.success) { + addZodDiagnostics(result.error, registryFile, context, { + itemName: getRawItemName(item), + itemIndex, + suggestion: + "Update the registry item so it matches the registry item schema.", + }) + return + } + + registryItems.push(result.data) + }) + + return registryItems +} + +function getOptionalString( + registry: Record, + key: string, + registryFile: string, + context: RegistryValidationContext +) { + const value = registry[key] + if (value === undefined) { + return undefined + } + + if (typeof value === "string") { + return value + } + + addDiagnostic(context, { + registryFile, + message: `${key}: Expected string, received ${typeof value}.`, + suggestion: `Update "${key}" so it is a string.`, + }) +} + +function getRawItemName(item: unknown) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return undefined + } + + const name = (item as Record).name + return typeof name === "string" ? name : undefined +} + +function addZodDiagnostics( + error: z.ZodError, + registryFile: string, + context: RegistryValidationContext, + options: { + itemName?: string + itemIndex?: number + pathPrefix?: (string | number)[] + suggestion?: string + } +) { + for (const issue of error.errors) { + addDiagnostic(context, { + registryFile, + itemName: options.itemName, + itemIndex: options.itemIndex, + message: formatZodIssue(issue, options.pathPrefix), + suggestion: options.suggestion, + }) + } +} + +function addDiagnostic( + context: RegistryValidationContext, + diagnostic: RegistryValidationDiagnostic +) { + context.diagnostics.push(diagnostic) +} + +function createValidationResult( + context: RegistryValidationContext, + items: RegistryItem[] +) { + return { + valid: context.diagnostics.length === 0, + cwd: context.cwd, + registryFiles: context.checkedRegistryFiles.size, + registryFilePaths: Array.from(context.checkedRegistryFiles), + items: context.itemsChecked, + diagnostics: context.diagnostics, + } +} + +function getRegistryRootDir(context: RegistryValidationContext) { + return context.usesInclude ? path.dirname(context.rootFile) : context.cwd +} + +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 chain + .map((file) => formatPath(file, path.dirname(chain[0]))) + .join(" -> ") +} + +function formatItemSource(source: RegistryItemSource | undefined, cwd: string) { + if (!source) { + return "unknown source" + } + + return `${formatPath(source.registryFile, cwd)} items[${source.itemIndex}]` +} + +function formatZodPath(issuePath: (string | number)[]) { + return issuePath.length ? issuePath.join(".") : "(root)" +} + +function formatZodIssue( + issue: z.ZodIssue, + pathPrefix: (string | number)[] = [] +) { + const path = [...pathPrefix, ...issue.path] + + if ( + issue.code === z.ZodIssueCode.invalid_union_discriminator && + issue.path.at(-1) === "type" + ) { + return `${formatZodPath(path)}: Invalid registry item type. Expected ${PUBLIC_REGISTRY_ITEM_TYPES.map( + (type) => `"${type}"` + ).join(" | ")}.` + } + + return `${formatZodPath(path)}: ${issue.message}` +} + +function formatPath(filePath: string, cwd: string) { + const relativePath = path.relative(cwd, filePath) + + if ( + relativePath && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) + ) { + return relativePath.split(path.sep).join("/") + } + + if (!relativePath) { + return "." + } + + return filePath +}