Initial Commit
This commit is contained in:
244
packages/ui/react/react-ui/scripts/build-registry.ts
Normal file
244
packages/ui/react/react-ui/scripts/build-registry.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user