Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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);
});
}