245 lines
8.1 KiB
TypeScript
245 lines
8.1 KiB
TypeScript
#!/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 `<package-root>/src/components/` (when it exists). For every
|
|
* direct subdirectory that contains a `<name>.tsx` file or an
|
|
* `index.ts` barrel, emits a `registry/r/<name>.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<BuildOptions> {
|
|
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<string[]> {
|
|
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 `<name>.tsx`. A handful (toast,
|
|
// use-toast-sonner-bridge) are JSX-free and ship as `<name>.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<void> {
|
|
await mkdir(path, { recursive: true });
|
|
}
|
|
|
|
export async function writeFileAtomic(
|
|
path: string,
|
|
content: string
|
|
): Promise<void> {
|
|
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<void> {
|
|
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);
|
|
});
|
|
}
|