#!/usr/bin/env bun /** * `build-registry.ts` — emits the shadcn-CLI-compatible registry tree * under `packages/react/react-ui/registry/r/` for the chassis. * * Real implementation that supersedes the placeholder shipped under * `ops/registry/scripts/build-registry.ts` (Wave 4 ops scaffolding). * * Behaviour: * * * Walks `/src/components/` (when it exists). For every * direct subdirectory that contains a `.tsx` file or an * `index.ts` barrel, emits a `registry/r/.json` manifest. * * Always emits `registry/r/index.json` with the manifest list (even * when the components dir is empty, which is the Wave 2 chassis * state — no components ship yet; they land per-batch in Wave 3a-h). * * Validates every emitted manifest against the upstream * shadcn registry-item JSON schema URL declared in the `$schema` * field. The schema itself is resolved at runtime by the consumer * when they invoke `shadcn add`. * * Future Wave 3 enhancements: * * * Strip internal-only OTel hooks (replaced with no-op shims in * ejected source) * * Rewrite `@suaveplan/react-i18n` imports to a configurable * consumer alias (default `@/lib/i18n`) * * Capture Tailwind/cva dependencies into `registryDependencies` * * Usage: * bun packages/react/react-ui/scripts/build-registry.ts * bun packages/react/react-ui/scripts/build-registry.ts --out ./tmp/r */ import { existsSync } from "node:fs"; import { mkdir, readdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REGISTRY_SCHEMA_URL = "https://ui.shadcn.com/schema/registry-item.json"; const REGISTRY_NAMESPACE = "@suaveplan"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = resolve(SCRIPT_DIR, ".."); const DEFAULT_COMPONENTS_DIR = join(PACKAGE_ROOT, "src", "components"); const DEFAULT_OUT_DIR = join(PACKAGE_ROOT, "registry", "r"); export interface RegistryItem { readonly $schema: string; readonly name: string; readonly type: "registry:ui"; readonly dependencies: readonly string[]; readonly registryDependencies: readonly string[]; readonly files: readonly RegistryFile[]; } export interface RegistryFile { readonly path: string; readonly type: "registry:ui"; } export interface RegistryIndex { readonly $schema: string; readonly name: string; readonly items: readonly RegistryIndexEntry[]; } export interface RegistryIndexEntry { readonly name: string; readonly type: "registry:ui"; } export interface BuildOptions { readonly componentsDir: string; readonly outDir: string; } export function parseArgs(argv: readonly string[]): Partial { const opts: { componentsDir?: string; outDir?: string } = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--out" && i + 1 < argv.length) { i += 1; opts.outDir = resolve(argv[i] as string); continue; } if (arg === "--components" && i + 1 < argv.length) { i += 1; opts.componentsDir = resolve(argv[i] as string); } } return opts; } export async function listComponentDirs( componentsDir: string ): Promise { if (!existsSync(componentsDir)) return []; const entries = await readdir(componentsDir, { withFileTypes: true }); const dirs: string[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const sub = join(componentsDir, entry.name); const hasIndex = existsSync(join(sub, "index.ts")) || existsSync(join(sub, "index.tsx")) || existsSync(join(sub, `${entry.name}.tsx`)); if (hasIndex) dirs.push(entry.name); } return dirs.sort(); } export function buildRegistryItem( name: string, componentsDir?: string ): RegistryItem { // Most components ship as `.tsx`. A handful (toast, // use-toast-sonner-bridge) are JSX-free and ship as `.ts`. // Prefer the `.tsx` form when present so consumers get the JSX // template; fall back to `.ts` when the component is hooks-only. const extension = componentsDir !== undefined && existsSync(join(componentsDir, name, `${name}.tsx`)) ? "tsx" : componentsDir !== undefined && existsSync(join(componentsDir, name, `${name}.ts`)) ? "ts" : "tsx"; return { $schema: REGISTRY_SCHEMA_URL, name: `${REGISTRY_NAMESPACE}/${name}`, type: "registry:ui", dependencies: [], registryDependencies: [], files: [{ path: `${name}.${extension}`, type: "registry:ui" }], }; } export function buildRegistryIndex(names: readonly string[]): RegistryIndex { return { $schema: REGISTRY_SCHEMA_URL, name: REGISTRY_NAMESPACE, items: names.map((n) => ({ name: `${REGISTRY_NAMESPACE}/${n}`, type: "registry:ui" as const, })), }; } export function serialize(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } /** Validate the emitted item against the bare shadcn schema shape. */ export function validateItem(value: RegistryItem): boolean { return ( typeof value.$schema === "string" && value.$schema === REGISTRY_SCHEMA_URL && typeof value.name === "string" && value.name.length > 0 && value.type === "registry:ui" && Array.isArray(value.dependencies) && Array.isArray(value.registryDependencies) && Array.isArray(value.files) && value.files.every( (f) => typeof f.path === "string" && f.type === "registry:ui" ) ); } export function validateIndex(value: RegistryIndex): boolean { return ( typeof value.$schema === "string" && value.$schema === REGISTRY_SCHEMA_URL && typeof value.name === "string" && Array.isArray(value.items) && value.items.every( (i) => typeof i.name === "string" && i.type === "registry:ui" ) ); } async function ensureDir(path: string): Promise { await mkdir(path, { recursive: true }); } export async function writeFileAtomic( path: string, content: string ): Promise { await ensureDir(dirname(path)); await writeFile(path, content, { encoding: "utf8" }); } export async function build(options: BuildOptions): Promise<{ readonly items: readonly string[]; readonly indexPath: string; }> { const componentNames = await listComponentDirs(options.componentsDir); if (componentNames.length === 0) { process.stdout.write( `build-registry: no components found under ${options.componentsDir}; emitting empty registry index\n` ); } for (const name of componentNames) { const item = buildRegistryItem(name, options.componentsDir); if (!validateItem(item)) { throw new Error(`build-registry: emitted item failed validation for ${name}`); } const out = join(options.outDir, `${name}.json`); await writeFileAtomic(out, serialize(item)); process.stdout.write(`wrote ${out}\n`); } const index = buildRegistryIndex(componentNames); if (!validateIndex(index)) { throw new Error("build-registry: emitted index failed validation"); } const indexPath = join(options.outDir, "index.json"); await writeFileAtomic(indexPath, serialize(index)); process.stdout.write(`wrote ${indexPath}\n`); return { items: componentNames, indexPath }; } async function main(): Promise { const parsed = parseArgs(process.argv.slice(2)); const options: BuildOptions = { componentsDir: parsed.componentsDir ?? DEFAULT_COMPONENTS_DIR, outDir: parsed.outDir ?? DEFAULT_OUT_DIR, }; await ensureDir(options.outDir); await build(options); } if (import.meta.main) { await main().catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`build-registry FAILED: ${message}\n`); process.exit(1); }); }