mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-02 17:08:39 +00:00
feat(registry): add validate command (#10715)
* feat: implement registry include * feat: updates * fix * refactor: implementation * fix(registry): correct directory registry json * fix(registry): stop warning for external registry dependencies * feat(registry): add validate command
This commit is contained in:
5
.changeset/icy-birds-warn.md
Normal file
5
.changeset/icy-birds-warn.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add shadcn registry validate command
|
||||
@@ -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)
|
||||
|
||||
137
packages/shadcn/src/commands/registry/validate.test.ts
Normal file
137
packages/shadcn/src/commands/registry/validate.test.ts
Normal file
@@ -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<string, string>) {
|
||||
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
|
||||
}
|
||||
168
packages/shadcn/src/commands/registry/validate.ts
Normal file
168
packages/shadcn/src/commands/registry/validate.ts
Normal file
@@ -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 <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (registryFile: string, opts) => {
|
||||
let validationSpinner: ReturnType<typeof spinner> | 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<ReturnType<typeof validateRegistry>>,
|
||||
validationSpinner: ReturnType<typeof spinner>
|
||||
) {
|
||||
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<ReturnType<typeof validateRegistry>>,
|
||||
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<ReturnType<typeof validateRegistry>>
|
||||
) {
|
||||
const groups = new Map<string, typeof report.diagnostics>()
|
||||
|
||||
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<typeof validateRegistry>
|
||||
>["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}`
|
||||
}
|
||||
594
packages/shadcn/src/registry/validate.test.ts
Normal file
594
packages/shadcn/src/registry/validate.test.ts
Normal file
@@ -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<string, string> = {}
|
||||
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<string, string>) {
|
||||
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}`)
|
||||
}
|
||||
723
packages/shadcn/src/registry/validate.ts
Normal file
723
packages/shadcn/src/registry/validate.ts
Normal file
@@ -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<string>
|
||||
checkedRegistryFiles: Set<string>
|
||||
itemsChecked: number
|
||||
itemSourcesByItem: Map<RegistryItem, RegistryItemSource>
|
||||
firstIncludedFrom: Map<string, string>
|
||||
}
|
||||
|
||||
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<RegistryItem[]> {
|
||||
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<string, RegistryItem>()
|
||||
|
||||
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<string, unknown>,
|
||||
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<string, unknown>).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
|
||||
}
|
||||
Reference in New Issue
Block a user