Initial Commit
This commit is contained in:
137
packages/ui/react/react-ui/scripts/build-registry.test.ts
Normal file
137
packages/ui/react/react-ui/scripts/build-registry.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
build,
|
||||
buildRegistryIndex,
|
||||
buildRegistryItem,
|
||||
parseArgs,
|
||||
serialize,
|
||||
validateIndex,
|
||||
validateItem,
|
||||
} from "./build-registry.js";
|
||||
|
||||
describe("build-registry", () => {
|
||||
it(
|
||||
"buildRegistryItem produces a shadcn-compatible item",
|
||||
{ timeout: 5000 },
|
||||
() => {
|
||||
const item = buildRegistryItem("button");
|
||||
expect(item.name).toBe("@suaveplan/button");
|
||||
expect(item.type).toBe("registry:ui");
|
||||
expect(item.files).toEqual([
|
||||
{ path: "button.tsx", type: "registry:ui" },
|
||||
]);
|
||||
expect(validateItem(item)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"buildRegistryIndex includes every supplied name",
|
||||
{ timeout: 5000 },
|
||||
() => {
|
||||
const idx = buildRegistryIndex(["button", "card"]);
|
||||
expect(idx.items).toHaveLength(2);
|
||||
expect(idx.items[0]?.name).toBe("@suaveplan/button");
|
||||
expect(validateIndex(idx)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"parseArgs picks up --out and --components flags",
|
||||
{ timeout: 5000 },
|
||||
() => {
|
||||
const parsed = parseArgs([
|
||||
"--out",
|
||||
"/tmp/out",
|
||||
"--components",
|
||||
"/tmp/src/components",
|
||||
]);
|
||||
expect(parsed.outDir).toBe("/tmp/out");
|
||||
expect(parsed.componentsDir).toBe("/tmp/src/components");
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"serialize emits canonical JSON with trailing newline",
|
||||
{ timeout: 5000 },
|
||||
() => {
|
||||
const out = serialize({ a: 1 });
|
||||
expect(out).toBe('{\n "a": 1\n}\n');
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"build emits an empty index when components dir does not exist",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "registry-empty-"));
|
||||
const componentsDir = join(dir, "src", "components");
|
||||
const outDir = join(dir, "registry", "r");
|
||||
const result = await build({ componentsDir, outDir });
|
||||
expect(result.items).toEqual([]);
|
||||
const idx = JSON.parse(await readFile(result.indexPath, "utf8"));
|
||||
expect(idx.items).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"build emits one manifest per component when present",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "registry-some-"));
|
||||
const componentsDir = join(dir, "src", "components");
|
||||
const outDir = join(dir, "registry", "r");
|
||||
await mkdir(join(componentsDir, "button"), { recursive: true });
|
||||
await writeFile(
|
||||
join(componentsDir, "button", "button.tsx"),
|
||||
"export const Button = () => null;"
|
||||
);
|
||||
await mkdir(join(componentsDir, "card"), { recursive: true });
|
||||
await writeFile(
|
||||
join(componentsDir, "card", "index.ts"),
|
||||
"export {};"
|
||||
);
|
||||
const result = await build({ componentsDir, outDir });
|
||||
expect(result.items.sort()).toEqual(["button", "card"]);
|
||||
const buttonItem = JSON.parse(
|
||||
await readFile(join(outDir, "button.json"), "utf8")
|
||||
);
|
||||
expect(buttonItem.name).toBe("@suaveplan/button");
|
||||
const idx = JSON.parse(await readFile(result.indexPath, "utf8"));
|
||||
expect(idx.items).toHaveLength(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"validateItem rejects malformed items",
|
||||
{ timeout: 5000 },
|
||||
() => {
|
||||
expect(
|
||||
validateItem({
|
||||
$schema: "wrong",
|
||||
name: "x",
|
||||
type: "registry:ui",
|
||||
dependencies: [],
|
||||
registryDependencies: [],
|
||||
files: [],
|
||||
} as never)
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"validateIndex rejects malformed indexes",
|
||||
{ timeout: 5000 },
|
||||
() => {
|
||||
expect(
|
||||
validateIndex({
|
||||
$schema: "wrong",
|
||||
name: "x",
|
||||
items: [],
|
||||
} as never)
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
77
packages/ui/react/react-ui/scripts/check-i18n-keys.md
Normal file
77
packages/ui/react/react-ui/scripts/check-i18n-keys.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# `check-i18n-keys`
|
||||
|
||||
## Purpose
|
||||
|
||||
Translation-key drift gate for `@suaveplan/react-ui`. Walks every `*.{ts,tsx}` under the package's source roots, extracts every `react-ui.*` translation-key reference from the documented call shapes, and diffs the discovered set against the canonical `src/locales/en-GB.json`. Reports `MISSING <key>` for keys referenced in code but absent from the bundle (consumers will hit a missing-key warning) and `ORPHAN <key>` for bundle keys nothing in source references (dead translation keys). Catches drift the moment a developer renames a key in code but forgets the bundle, or vice versa.
|
||||
|
||||
## Features
|
||||
|
||||
- Pattern-based extraction over six chassis-recognised call shapes: `t("react-ui.…")`, `tDynamic(...)`, `i18nKey="…"` JSX attribute, `i18nKey: "…"` object literal, `<name>Key={"…"}` prop default, `?? "react-ui.…"` fallback shorthand, `const DEFAULT_*_KEY = "…"` module-scope literal, `labelKey: "…"` plugin shape.
|
||||
- Source location (`<file>:<line>`) attached to every MISSING diagnostic for quick navigation.
|
||||
- `--strict` flag promotes ORPHAN diagnostics to errors. Default exits 0 on ORPHAN-only (warn) so the gate ships green even when the bundle has pre-existing dead keys; CI can opt into strict mode after a cleanup pass.
|
||||
- Wired into `bunx turbo lint` — every commit in CI runs the default mode.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```bash
|
||||
bun run check:i18n-keys
|
||||
# check-i18n-keys: clean — 23 keys in code, 23 keys in bundle.
|
||||
```
|
||||
|
||||
When a key is missing:
|
||||
|
||||
```bash
|
||||
bun run check:i18n-keys
|
||||
# MISSING react-ui.foo.absent at src/components/foo/foo.tsx:42
|
||||
# check-i18n-keys: 1 missing, 0 orphan (orphan = warn)
|
||||
# (exit 1)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Strict mode for a periodic bundle-cleanup pass:
|
||||
|
||||
```bash
|
||||
bun run check:i18n-keys:strict
|
||||
# ORPHAN react-ui.popover.close
|
||||
# ORPHAN react-ui.alertDialog.cancel
|
||||
# check-i18n-keys: 0 missing, 2 orphan (strict)
|
||||
# (exit 1)
|
||||
```
|
||||
|
||||
The script's exported helpers can be reused by other tooling (e.g. a CI report aggregator):
|
||||
|
||||
```ts
|
||||
import {
|
||||
diff,
|
||||
extractKeysFromSource,
|
||||
loadBundleKeys,
|
||||
} from "./check-i18n-keys.js";
|
||||
|
||||
const sourceKeys = (await extractKeysFromSource(files, root)).keys;
|
||||
const bundleKeys = await loadBundleKeys(bundlePath);
|
||||
const diagnostics = diff(sourceKeys, bundleKeys);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### CLI
|
||||
|
||||
`bun scripts/check-i18n-keys.ts [--strict]`
|
||||
|
||||
- Default mode: exits non-zero only when at least one MISSING diagnostic appears. ORPHANs are printed as warnings.
|
||||
- `--strict`: exits non-zero on any diagnostic (MISSING or ORPHAN).
|
||||
|
||||
### Exported helpers
|
||||
|
||||
- `extractKeysFromSource(files, rootForRelative): Promise<{ keys: Map<key, location> }>` — walks the supplied source files, returns a map keyed by translation key, value `<relative-path>:<line>`.
|
||||
- `loadBundleKeys(bundlePath): Promise<Set<string>>` — reads the bundle JSON and returns the subset of keys in the `react-ui.*` namespace.
|
||||
- `diff(sourceKeys, bundleKeys): Diagnostic[]` — pure function returning sorted diagnostics (MISSING before ORPHAN, alphabetic within each kind).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Pattern-based instead of AST-based — no `@babel/parser` dep. The supported call shapes are a closed set documented at the top of the script. New idioms (e.g. a future `const KEYS = { close: "react-ui.foo" }` const-record style) need a new pattern; the script falls into the catch-all-of-last-resort `?? "react-ui.…"` only when the value lives in a discriminating context.
|
||||
- The extraction explicitly EXCLUDES `*.test.tsx`, `*.stories.tsx`, `*.stories.test.tsx`, and `*.bench.ts` — test fixtures and stories often reference keys that exist in the bundle for component contracts, not for the test's own runtime use; treating them as references would mask real orphans.
|
||||
- Scan roots: `src/components`, `src/i18n`, `src/theme`, `src/pack`, `src/provider`, `src/hooks`, `src/errors`. Any future surface that ships `react-ui.*` keys must be added here.
|
||||
- The default-vs-strict split is deliberate. Pre-existing orphans in the bundle (`react-ui.empty.defaultDescription`, `react-ui.popover.close`, …) are public-API keys consumers may pass via `*Key` prop overrides; removing them would be a breaking change. Default mode warns; strict mode lets the team enforce zero-orphan after a bundle cleanup change.
|
||||
- The script is a `Bun.import.meta.main` entry point; `process.exit(await main())` is the only top-level side effect. Imports are pure helpers reusable from tests.
|
||||
171
packages/ui/react/react-ui/scripts/check-i18n-keys.test.ts
Normal file
171
packages/ui/react/react-ui/scripts/check-i18n-keys.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for `check-i18n-keys.ts`.
|
||||
*
|
||||
* Build a fixtures filesystem on disk under `mkdtemp` and call the
|
||||
* exported helpers (`extractKeysFromSource`, `loadBundleKeys`,
|
||||
* `diff`) directly. The script's `main()` integrates with
|
||||
* `process.argv` / `process.exit` so it is exercised end-to-end via
|
||||
* a `Bun.spawn` round-trip in the last test.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
diff,
|
||||
extractKeysFromSource,
|
||||
loadBundleKeys,
|
||||
} from "./check-i18n-keys.js";
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "i18n-keys-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFixture(rel: string, content: string): Promise<string> {
|
||||
const full = join(workDir, rel);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, content, "utf8");
|
||||
return full;
|
||||
}
|
||||
|
||||
describe("extractKeysFromSource", () => {
|
||||
it("captures t() calls with double quotes", { timeout: 5000 }, async () => {
|
||||
const f = await writeFixture(
|
||||
"a.tsx",
|
||||
`const out = t("react-ui.foo.bar");`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.has("react-ui.foo.bar")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures t() calls with single quotes", { timeout: 5000 }, async () => {
|
||||
const f = await writeFixture(
|
||||
"a.tsx",
|
||||
`const out = t('react-ui.foo.bar');`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.has("react-ui.foo.bar")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures i18nKey JSX attribute", { timeout: 5000 }, async () => {
|
||||
const f = await writeFixture(
|
||||
"a.tsx",
|
||||
`<FormattedMessage i18nKey="react-ui.greeting" />`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.has("react-ui.greeting")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures the DEFAULT_*_KEY const idiom", { timeout: 5000 }, async () => {
|
||||
const f = await writeFixture(
|
||||
"a.tsx",
|
||||
`const DEFAULT_LABEL_KEY = "react-ui.thing.label";`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.has("react-ui.thing.label")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures `?? \"react-ui.…\"` fallback shorthand", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const f = await writeFixture(
|
||||
"a.tsx",
|
||||
`const k = labelKey ?? "react-ui.fallback.label";`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.has("react-ui.fallback.label")).toBe(true);
|
||||
});
|
||||
|
||||
it("records the source location for each key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const f = await writeFixture(
|
||||
"deep/nested.tsx",
|
||||
`// header\nconst k = t("react-ui.line.two");`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.get("react-ui.line.two")).toMatch(/deep\/nested\.tsx:2$/);
|
||||
});
|
||||
|
||||
it("ignores non-namespace strings", { timeout: 5000 }, async () => {
|
||||
const f = await writeFixture(
|
||||
"a.tsx",
|
||||
`const k = t("other.namespace.key");`
|
||||
);
|
||||
const { keys } = await extractKeysFromSource([f], workDir);
|
||||
expect(keys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadBundleKeys", () => {
|
||||
it("loads only keys in the react-ui namespace", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = join(workDir, "bundle.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
"react-ui.foo": "Foo",
|
||||
"react-ui.bar": "Bar",
|
||||
"other.namespace.key": "other",
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
const keys = await loadBundleKeys(path);
|
||||
expect(keys.has("react-ui.foo")).toBe(true);
|
||||
expect(keys.has("react-ui.bar")).toBe(true);
|
||||
expect(keys.has("other.namespace.key")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff", () => {
|
||||
it("reports MISSING for code-only keys", { timeout: 5000 }, () => {
|
||||
const codeKeys = new Map([["react-ui.code.only", "src/x.tsx:1"]]);
|
||||
const bundleKeys = new Set(["react-ui.bundled"]);
|
||||
const diags = diff(codeKeys, bundleKeys);
|
||||
expect(
|
||||
diags.find((d) => d.kind === "MISSING")?.key
|
||||
).toBe("react-ui.code.only");
|
||||
});
|
||||
|
||||
it("reports ORPHAN for bundle-only keys", { timeout: 5000 }, () => {
|
||||
const codeKeys = new Map<string, string>();
|
||||
const bundleKeys = new Set(["react-ui.bundle.only"]);
|
||||
const diags = diff(codeKeys, bundleKeys);
|
||||
expect(
|
||||
diags.find((d) => d.kind === "ORPHAN")?.key
|
||||
).toBe("react-ui.bundle.only");
|
||||
});
|
||||
|
||||
it("returns empty diagnostics on a clean source/bundle pair", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codeKeys = new Map([["react-ui.k", "src/x.tsx:1"]]);
|
||||
const bundleKeys = new Set(["react-ui.k"]);
|
||||
expect(diff(codeKeys, bundleKeys)).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders MISSING before ORPHAN, then alphabetically", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codeKeys = new Map([
|
||||
["react-ui.b", "x:1"],
|
||||
["react-ui.a", "x:2"],
|
||||
]);
|
||||
const bundleKeys = new Set(["react-ui.zzz", "react-ui.aaa"]);
|
||||
const diags = diff(codeKeys, bundleKeys);
|
||||
expect(diags.map((d) => `${d.kind}:${d.key}`)).toEqual([
|
||||
"MISSING:react-ui.a",
|
||||
"MISSING:react-ui.b",
|
||||
"ORPHAN:react-ui.aaa",
|
||||
"ORPHAN:react-ui.zzz",
|
||||
]);
|
||||
});
|
||||
});
|
||||
239
packages/ui/react/react-ui/scripts/check-i18n-keys.ts
Normal file
239
packages/ui/react/react-ui/scripts/check-i18n-keys.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* `check-i18n-keys.ts` — translation-key drift gate for
|
||||
* `@suaveplan/react-ui`.
|
||||
*
|
||||
* Walks every `*.{ts,tsx}` under `src/components/**` and `src/i18n/**`
|
||||
* and extracts translation-key references from these call shapes:
|
||||
*
|
||||
* - `t("react-ui.…")` / `t('react-ui.…')`
|
||||
* - `tDynamic("react-ui.…")` / `tDynamic('react-ui.…')`
|
||||
* - `<FormattedMessage i18nKey="react-ui.…" />` / `i18nKey='…'`
|
||||
* - `<Trans i18nKey="…">` (existing components)
|
||||
* - `i18nKey: "react-ui.…"` (object-literal column-config style)
|
||||
* - `(.+)Key={?["']react-ui\.…["']` for prop-drilled keys
|
||||
* (Pagination, Calendar, DataTable name multiple `*Key` props)
|
||||
* - `?? "react-ui.…"` (default-key fallback pattern)
|
||||
*
|
||||
* The script then diffs the discovered set against the canonical
|
||||
* `src/locales/en-GB.json`, reporting:
|
||||
*
|
||||
* - `MISSING <key> at <file>:<line>` — referenced in code but absent
|
||||
* from the bundle (consumers will hit a missing-key warning).
|
||||
* - `ORPHAN <key>` — present in the bundle but never referenced
|
||||
* (dead translation key).
|
||||
*
|
||||
* Exits non-zero on any diagnostic. Wired into `bunx turbo lint`.
|
||||
*
|
||||
* Pattern-based extraction is intentional — the package has no
|
||||
* `@babel/parser` dep and the supported call shapes are a closed set.
|
||||
* The script accepts both single and double-quoted attribute values
|
||||
* and ignores keys outside the `react-ui.…` namespace.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PACKAGE_ROOT = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const SCAN_ROOTS = [
|
||||
join(PACKAGE_ROOT, "src", "components"),
|
||||
join(PACKAGE_ROOT, "src", "i18n"),
|
||||
join(PACKAGE_ROOT, "src", "theme"),
|
||||
join(PACKAGE_ROOT, "src", "pack"),
|
||||
join(PACKAGE_ROOT, "src", "provider"),
|
||||
join(PACKAGE_ROOT, "src", "hooks"),
|
||||
join(PACKAGE_ROOT, "src", "errors"),
|
||||
];
|
||||
const BUNDLE_PATH = join(PACKAGE_ROOT, "src", "locales", "en-GB.json");
|
||||
const NAMESPACE = "react-ui.";
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly kind: "MISSING" | "ORPHAN";
|
||||
readonly key: string;
|
||||
/** Source path + line for MISSING diagnostics; absent for ORPHAN. */
|
||||
readonly location?: string;
|
||||
}
|
||||
|
||||
export interface KeyExtraction {
|
||||
readonly keys: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
const KEY_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/\bt\s*\(\s*["'](react-ui\.[^"']+)["']/g,
|
||||
/\btDynamic\s*\(\s*["'](react-ui\.[^"']+)["']/g,
|
||||
/\bi18nKey\s*=\s*["'](react-ui\.[^"']+)["']/g,
|
||||
/\bi18nKey\s*:\s*["'](react-ui\.[^"']+)["']/g,
|
||||
/\b\w+Key\s*=\s*\{?\s*["'](react-ui\.[^"']+)["']/g,
|
||||
/\?\?\s*["'](react-ui\.[^"']+)["']/g,
|
||||
// `const DEFAULT_*_KEY = "react-ui.…"` — the chassis convention
|
||||
// for module-scoped default keys consumed via the *Key prop
|
||||
// pattern above.
|
||||
/(?:const|let|var)\s+\w+\s*(?::[^=]+)?=\s*["'](react-ui\.[^"']+)["']/g,
|
||||
// `labelKey: "react-ui.…"` — theme-plugin / pack-plugin literal
|
||||
// (chassis convention).
|
||||
/\blabelKey\s*:\s*["'](react-ui\.[^"']+)["']/g,
|
||||
// `as ThemePlugin["labelKey"]` cast pattern — keys forwarded
|
||||
// through `as const` annotations into a typed slot.
|
||||
/["'](react-ui\.[a-zA-Z][a-zA-Z0-9.]*)["']\s*as\s+const\b/g,
|
||||
];
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...(await walk(full)));
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".test.tsx") &&
|
||||
!entry.name.endsWith(".stories.tsx") &&
|
||||
!entry.name.endsWith(".stories.test.tsx") &&
|
||||
!entry.name.endsWith(".bench.ts")
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineNumber(source: string, offset: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < offset && i < source.length; i += 1) {
|
||||
if (source.charCodeAt(i) === 10) line += 1;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export async function extractKeysFromSource(
|
||||
sourceFiles: ReadonlyArray<string>,
|
||||
rootForRelative: string
|
||||
): Promise<KeyExtraction> {
|
||||
const keys = new Map<string, string>();
|
||||
for (const file of sourceFiles) {
|
||||
const text = await readFile(file, "utf8");
|
||||
for (const pattern of KEY_PATTERNS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const key = match[1];
|
||||
if (typeof key !== "string" || keys.has(key)) continue;
|
||||
const idx = match.index ?? 0;
|
||||
const ln = lineNumber(text, idx);
|
||||
keys.set(key, `${relative(rootForRelative, file)}:${ln}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { keys };
|
||||
}
|
||||
|
||||
export function diff(
|
||||
sourceKeys: ReadonlyMap<string, string>,
|
||||
bundleKeys: ReadonlySet<string>
|
||||
): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
for (const [key, location] of sourceKeys) {
|
||||
if (!bundleKeys.has(key)) {
|
||||
diagnostics.push({ kind: "MISSING", key, location });
|
||||
}
|
||||
}
|
||||
for (const key of bundleKeys) {
|
||||
if (!sourceKeys.has(key)) {
|
||||
diagnostics.push({ kind: "ORPHAN", key });
|
||||
}
|
||||
}
|
||||
diagnostics.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "MISSING" ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export async function loadBundleKeys(
|
||||
bundlePath: string
|
||||
): Promise<Set<string>> {
|
||||
const raw = await readFile(bundlePath, "utf8");
|
||||
const parsed: Record<string, unknown> = JSON.parse(raw);
|
||||
const out = new Set<string>();
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (key.startsWith(NAMESPACE)) out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
readonly strict: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: ReadonlyArray<string>): CheckOptions {
|
||||
let strict = false;
|
||||
for (const arg of argv) {
|
||||
if (arg === "--strict") strict = true;
|
||||
}
|
||||
return { strict };
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const sourceFiles: string[] = [];
|
||||
for (const root of SCAN_ROOTS) {
|
||||
try {
|
||||
sourceFiles.push(...(await walk(root)));
|
||||
} catch (err) {
|
||||
if (
|
||||
(err as NodeJS.ErrnoException).code !== "ENOENT" &&
|
||||
(err as NodeJS.ErrnoException).code !== "ENOTDIR"
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sourceFiles.length === 0) {
|
||||
console.error(
|
||||
`check-i18n-keys: no source files found under any of ${SCAN_ROOTS.join(", ")}`
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
const { keys: sourceKeys } = await extractKeysFromSource(
|
||||
sourceFiles,
|
||||
PACKAGE_ROOT
|
||||
);
|
||||
const bundleKeys = await loadBundleKeys(BUNDLE_PATH);
|
||||
const diagnostics = diff(sourceKeys, bundleKeys);
|
||||
|
||||
if (diagnostics.length === 0) {
|
||||
console.log(
|
||||
`check-i18n-keys: clean — ${sourceKeys.size} keys in code, ${bundleKeys.size} keys in bundle.`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const missingCount = diagnostics.filter((d) => d.kind === "MISSING").length;
|
||||
const orphanCount = diagnostics.length - missingCount;
|
||||
|
||||
for (const d of diagnostics) {
|
||||
if (d.kind === "MISSING") {
|
||||
console.error(`MISSING ${d.key} at ${d.location ?? "?"}`);
|
||||
} else if (opts.strict) {
|
||||
console.error(`ORPHAN ${d.key}`);
|
||||
} else {
|
||||
console.warn(`ORPHAN ${d.key} (warn — use --strict to fail)`);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
`check-i18n-keys: ${missingCount} missing, ${orphanCount} orphan` +
|
||||
(opts.strict ? " (strict)" : " (orphan = warn)")
|
||||
);
|
||||
if (missingCount > 0) return 1;
|
||||
if (opts.strict && orphanCount > 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
process.exit(await main());
|
||||
}
|
||||
109
packages/ui/react/react-ui/scripts/check-locale-completeness.ts
Normal file
109
packages/ui/react/react-ui/scripts/check-locale-completeness.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* `check-locale-completeness.ts` — assert every key in
|
||||
* `<package>/src/locales/en-GB.json` exists in every other locale
|
||||
* bundle shipped by the package. Pure file walk; no transitive deps on
|
||||
* the package source.
|
||||
*
|
||||
* Usage:
|
||||
* bun packages/react/react-ui/scripts/check-locale-completeness.ts <package-root>
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every key parity-complete
|
||||
* 1 — at least one key missing
|
||||
* 2 — internal failure
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface BundleDiff {
|
||||
readonly locale: string;
|
||||
readonly missing: ReadonlyArray<string>;
|
||||
readonly extra: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export async function checkLocaleCompleteness(
|
||||
packageRoot: string
|
||||
): Promise<BundleDiff[]> {
|
||||
const localesDir = join(resolve(packageRoot), "src", "locales");
|
||||
const enPath = join(localesDir, "en-GB.json");
|
||||
const enKeys = new Set(
|
||||
Object.keys(JSON.parse(await readFile(enPath, "utf8")))
|
||||
);
|
||||
const entries = await readdir(localesDir);
|
||||
const diffs: BundleDiff[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry === "en-GB.json") continue;
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
const otherPath = join(localesDir, entry);
|
||||
const other = JSON.parse(await readFile(otherPath, "utf8")) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const otherKeys = new Set(Object.keys(other));
|
||||
const missing: string[] = [];
|
||||
for (const k of enKeys) {
|
||||
if (!otherKeys.has(k)) missing.push(k);
|
||||
}
|
||||
const extra: string[] = [];
|
||||
for (const k of otherKeys) {
|
||||
if (!enKeys.has(k)) extra.push(k);
|
||||
}
|
||||
if (missing.length > 0 || extra.length > 0) {
|
||||
diffs.push({
|
||||
locale: entry.replace(/\.json$/, ""),
|
||||
missing,
|
||||
extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
function format(diffs: ReadonlyArray<BundleDiff>): string {
|
||||
if (diffs.length === 0) {
|
||||
return "check-locale-completeness: every locale parity-complete.";
|
||||
}
|
||||
const lines: string[] = [
|
||||
`check-locale-completeness: ${diffs.length} locale(s) failed parity.`,
|
||||
];
|
||||
for (const d of diffs) {
|
||||
lines.push(` ${d.locale}:`);
|
||||
if (d.missing.length > 0) {
|
||||
lines.push(" missing keys:");
|
||||
for (const k of d.missing) lines.push(` - ${k}`);
|
||||
}
|
||||
if (d.extra.length > 0) {
|
||||
lines.push(" extra keys (not in en-GB):");
|
||||
for (const k of d.extra) lines.push(` - ${k}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0) {
|
||||
process.stderr.write(
|
||||
"Usage: check-locale-completeness.ts <package-root>\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const root = argv[0] as string;
|
||||
const diffs = await checkLocaleCompleteness(root);
|
||||
const message = format(diffs);
|
||||
if (diffs.length === 0) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main().catch((err: unknown) => {
|
||||
process.stderr.write(`internal error: ${(err as Error).message}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { checkRoots } from "../../../../../scripts/lint/check-no-hardcoded-strings.js";
|
||||
|
||||
async function makeTempFile(name: string, body: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "no-hardcoded-strings-"));
|
||||
const file = join(dir, name);
|
||||
await writeFile(file, body, "utf8");
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("check-no-hardcoded-strings", () => {
|
||||
it(
|
||||
"passes a t-wrapped JSX text node",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"ok.tsx",
|
||||
`import { useTranslation } from '@suaveplan/react-i18n';
|
||||
export function X() {
|
||||
const { t } = useTranslation();
|
||||
return <button type="button">{t('react-ui.themeToggle.label')}</button>;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fails a bare JSX text node",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"bad.tsx",
|
||||
`export function X() {
|
||||
return <button type="button">Close</button>;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
|
||||
expect(diagnostics[0]?.rule).toBe("jsx-text");
|
||||
expect(diagnostics[0]?.snippet).toBe("Close");
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fails a string literal aria-label",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"aria.tsx",
|
||||
`export function X() {
|
||||
return <button type="button" aria-label="Close" />;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
|
||||
expect(diagnostics[0]?.rule).toBe("jsx-attr-aria-label");
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fails an object-literal label string",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"obj.ts",
|
||||
`export const fields = { email: { label: 'Email' } };
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
|
||||
expect(diagnostics[0]?.rule).toBe("object-label");
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fails a JSX expression string-literal child",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"literal.tsx",
|
||||
`export function X() {
|
||||
return <span>{'hardcoded'}</span>;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"fails a string-literal placeholder attribute",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"placeholder.tsx",
|
||||
`export function X() {
|
||||
return <input type="text" placeholder="Email address" />;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics.length).toBeGreaterThanOrEqual(1);
|
||||
expect(diagnostics[0]?.rule).toBe("jsx-attr-placeholder");
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"skips test/spec/stories files via the skip suffix list",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const root = await makeTempFile(
|
||||
"thing.test.tsx",
|
||||
`export function X() {
|
||||
return <button type="button">hardcoded but exempt</button>;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root }]);
|
||||
expect(diagnostics).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"skips spike directories",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "spike-skip-"));
|
||||
const spikeDir = join(dir, "spike");
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.mkdir(spikeDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
join(spikeDir, "x.tsx"),
|
||||
`export function X() {
|
||||
return <button type="button">spike literal</button>;
|
||||
}
|
||||
`
|
||||
);
|
||||
const diagnostics = await checkRoots([{ root: dir }]);
|
||||
expect(diagnostics).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
63
packages/ui/react/react-ui/scripts/check-registry-eject.md
Normal file
63
packages/ui/react/react-ui/scripts/check-registry-eject.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# `check-registry-eject`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shadcn-eject smoke gate for `@suaveplan/react-ui`. Verifies the `registry/r/*.json` tree against the package source so a downstream consumer running `shadcn add @suaveplan/<name>` always finds the files the registry promised. Catches the realistic drift modes today: a registry entry whose listed file vanished (rename / restructure), a component directory shipped without a registry entry (forgot to regenerate), an orphaned registry entry whose component was deleted (forgot to clean up), and entries that fail JSON or shape validation. Runs as part of `bunx turbo lint` on every commit.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-entry validation: shape (`name`, `type`, `files`), JSON parse, non-empty `files[]`.
|
||||
- Per-file existence: every `files[].path` resolves to a real file under `src/components/<name>/`.
|
||||
- Bidirectional drift detection: registry entries without matching component directories AND component directories without registry entries both fail.
|
||||
- Sorted, stable diagnostic output for diff-friendly CI logs.
|
||||
- Pure helper exports (`audit`, `loadEntry`) reusable from tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```bash
|
||||
bun run check:registry-eject
|
||||
# check-registry-eject: clean — 51 registry entries match 51 component directories.
|
||||
```
|
||||
|
||||
When a registry entry lists a missing file:
|
||||
|
||||
```bash
|
||||
bun run check:registry-eject
|
||||
# MISSING_SOURCE_FILE registry/r/toast.json: file 'toast.tsx' does not exist at src/components/toast/toast.tsx
|
||||
# check-registry-eject: 1 diagnostic(s) — see above.
|
||||
# (exit 1)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The exported helpers can be reused — for example, a CI report generator that walks both registries (`react-ui` + a future `react-ui-social`) and emits a unified report:
|
||||
|
||||
```ts
|
||||
import { audit } from "@suaveplan/react-ui/scripts/check-registry-eject";
|
||||
|
||||
const diagnostics = await audit({
|
||||
registryEntries: await listEntries(packageA),
|
||||
componentDirs: await listDirs(packageA),
|
||||
componentsDir: join(packageA, "src/components"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### CLI
|
||||
|
||||
`bun scripts/check-registry-eject.ts`
|
||||
|
||||
Exits 0 on a clean tree; exits 1 on any diagnostic; exits 2 when the registry directory or components directory is missing entirely (configuration error).
|
||||
|
||||
### Exported helpers
|
||||
|
||||
- `loadEntry(entryPath): Promise<RegistryEntry | { error }>` — reads + validates a single `registry/r/<name>.json` entry. Returns `{ error: string }` on any structural failure (missing field, wrong type, malformed JSON).
|
||||
- `audit({ registryEntries, componentDirs, componentsDir }): Promise<Diagnostic[]>` — pure function returning sorted diagnostics. The four diagnostic kinds are: `MISSING_REGISTRY_ENTRY`, `MISSING_SOURCE_FILE`, `EMPTY_FILES_LIST`, `MALFORMED_ENTRY`, `ORPHAN_REGISTRY_ENTRY`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The gate is intentionally narrower than the proposal's full vision — it verifies file presence + entry shape, NOT that the ejected source compiles against a synthesized consumer `tsconfig.json`. The synthesized-tsconfig roundtrip depends on the registry transform itself (rewriting `@suaveplan/react-ui` imports to consumer-local paths) which today is a passthrough — `build-registry.ts` ships source verbatim, so a consumer's `shadcn add` already gets compilable source. When the transform lands as a callable helper (per the comment block in `build-registry.ts`), this gate's `audit()` will be extended to invoke it and then run `tsc --noEmit` against the transformed tree.
|
||||
- `index.json` is excluded from the per-entry walk — it is the manifest of manifests, not an individual component.
|
||||
- Component directories whose only files are barrels (`index.ts` only, no `<name>.tsx`/`<name>.ts`) are NOT detected as component directories. The list-detection mirrors `build-registry.ts`'s heuristic.
|
||||
- Both `.tsx` and `.ts` extensions are valid for the file listed in `files[]`. Components like `<Toast>` that ship as JSX-free TypeScript ship as `.ts`; the registry entry for them lists `<name>.ts` and the gate accepts it.
|
||||
200
packages/ui/react/react-ui/scripts/check-registry-eject.test.ts
Normal file
200
packages/ui/react/react-ui/scripts/check-registry-eject.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Tests for `check-registry-eject.ts`.
|
||||
*
|
||||
* Builds fixtures filesystems on disk under `mkdtemp` and exercises
|
||||
* the exported helpers (`audit`, `loadEntry`) directly. The script's
|
||||
* `main()` is exercised indirectly through these helpers — its
|
||||
* top-level behaviour (process.exit) is covered by the lint gate
|
||||
* itself running on real source.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { audit, loadEntry } from "./check-registry-eject.js";
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "registry-eject-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeJson(rel: string, body: unknown): Promise<string> {
|
||||
const full = join(workDir, rel);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, JSON.stringify(body, null, 2), "utf8");
|
||||
return full;
|
||||
}
|
||||
|
||||
async function writeFixture(rel: string, content = ""): Promise<string> {
|
||||
const full = join(workDir, rel);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, content, "utf8");
|
||||
return full;
|
||||
}
|
||||
|
||||
describe("loadEntry", () => {
|
||||
it("loads a valid registry entry", { timeout: 5000 }, async () => {
|
||||
const entry = await writeJson("button.json", {
|
||||
name: "@suaveplan/button",
|
||||
type: "registry:ui",
|
||||
files: [{ path: "button.tsx", type: "registry:ui" }],
|
||||
});
|
||||
const result = await loadEntry(entry);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an entry without a name", { timeout: 5000 }, async () => {
|
||||
const entry = await writeJson("bad.json", {
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
});
|
||||
const result = await loadEntry(entry);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an entry with non-array files", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const entry = await writeJson("bad.json", {
|
||||
name: "x",
|
||||
type: "registry:ui",
|
||||
files: "oops",
|
||||
});
|
||||
const result = await loadEntry(entry);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON", { timeout: 5000 }, async () => {
|
||||
const entry = await writeFixture("bad.json", "{ not json");
|
||||
const result = await loadEntry(entry);
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit", () => {
|
||||
it("returns no diagnostics on a clean fixtures tree", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const componentsDir = join(workDir, "src/components");
|
||||
await writeFixture("src/components/button/button.tsx", "export {}");
|
||||
const entry = await writeJson("registry/r/button.json", {
|
||||
name: "@x/button",
|
||||
type: "registry:ui",
|
||||
files: [{ path: "button.tsx", type: "registry:ui" }],
|
||||
});
|
||||
const diagnostics = await audit({
|
||||
registryEntries: [entry],
|
||||
componentDirs: ["button"],
|
||||
componentsDir,
|
||||
});
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a component directory with no registry entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const componentsDir = join(workDir, "src/components");
|
||||
await writeFixture("src/components/button/button.tsx", "export {}");
|
||||
const diagnostics = await audit({
|
||||
registryEntries: [],
|
||||
componentDirs: ["button"],
|
||||
componentsDir,
|
||||
});
|
||||
expect(
|
||||
diagnostics.find(
|
||||
(d) => d.kind === "MISSING_REGISTRY_ENTRY"
|
||||
)?.message
|
||||
).toMatch(/button/);
|
||||
});
|
||||
|
||||
it("flags a registry entry whose source file is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const componentsDir = join(workDir, "src/components");
|
||||
await mkdir(join(componentsDir, "ghost"), { recursive: true });
|
||||
const entry = await writeJson("registry/r/ghost.json", {
|
||||
name: "@x/ghost",
|
||||
type: "registry:ui",
|
||||
files: [{ path: "ghost.tsx", type: "registry:ui" }],
|
||||
});
|
||||
const diagnostics = await audit({
|
||||
registryEntries: [entry],
|
||||
componentDirs: ["ghost"],
|
||||
componentsDir,
|
||||
});
|
||||
expect(
|
||||
diagnostics.find((d) => d.kind === "MISSING_SOURCE_FILE")?.message
|
||||
).toMatch(/ghost\.tsx/);
|
||||
});
|
||||
|
||||
it("flags an orphan registry entry (no matching component dir)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const componentsDir = join(workDir, "src/components");
|
||||
await writeFixture(
|
||||
"src/components/button/button.tsx",
|
||||
"export {}"
|
||||
);
|
||||
const entryButton = await writeJson("registry/r/button.json", {
|
||||
name: "@x/button",
|
||||
type: "registry:ui",
|
||||
files: [{ path: "button.tsx", type: "registry:ui" }],
|
||||
});
|
||||
const entryStale = await writeJson("registry/r/removed.json", {
|
||||
name: "@x/removed",
|
||||
type: "registry:ui",
|
||||
files: [{ path: "removed.tsx", type: "registry:ui" }],
|
||||
});
|
||||
const diagnostics = await audit({
|
||||
registryEntries: [entryButton, entryStale],
|
||||
componentDirs: ["button"],
|
||||
componentsDir,
|
||||
});
|
||||
expect(
|
||||
diagnostics.find(
|
||||
(d) => d.kind === "ORPHAN_REGISTRY_ENTRY"
|
||||
)?.message
|
||||
).toMatch(/removed/);
|
||||
});
|
||||
|
||||
it("flags an entry with empty files[]", { timeout: 5000 }, async () => {
|
||||
const componentsDir = join(workDir, "src/components");
|
||||
await writeFixture("src/components/foo/foo.tsx", "export {}");
|
||||
const entry = await writeJson("registry/r/foo.json", {
|
||||
name: "@x/foo",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
});
|
||||
const diagnostics = await audit({
|
||||
registryEntries: [entry],
|
||||
componentDirs: ["foo"],
|
||||
componentsDir,
|
||||
});
|
||||
expect(
|
||||
diagnostics.find((d) => d.kind === "EMPTY_FILES_LIST")?.message
|
||||
).toMatch(/foo\.json/);
|
||||
});
|
||||
|
||||
it("flags malformed JSON entries", { timeout: 5000 }, async () => {
|
||||
const componentsDir = join(workDir, "src/components");
|
||||
await writeFixture("src/components/foo/foo.tsx", "");
|
||||
const entry = await writeFixture(
|
||||
"registry/r/foo.json",
|
||||
"{ not json"
|
||||
);
|
||||
const diagnostics = await audit({
|
||||
registryEntries: [entry],
|
||||
componentDirs: ["foo"],
|
||||
componentsDir,
|
||||
});
|
||||
expect(
|
||||
diagnostics.find((d) => d.kind === "MALFORMED_ENTRY")?.message
|
||||
).toMatch(/foo\.json/);
|
||||
});
|
||||
});
|
||||
243
packages/ui/react/react-ui/scripts/check-registry-eject.ts
Normal file
243
packages/ui/react/react-ui/scripts/check-registry-eject.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* `check-registry-eject.ts` — shadcn-eject smoke gate for
|
||||
* `@suaveplan/react-ui`.
|
||||
*
|
||||
* Verifies the shadcn `registry/r/*.json` tree against the package
|
||||
* source so a downstream consumer running `shadcn add` always finds
|
||||
* the files the registry promised. Specifically:
|
||||
*
|
||||
* 1. Every `registry/r/<name>.json` entry SHALL list at least one
|
||||
* `files[]` path; every listed path SHALL resolve to an
|
||||
* existing file under `src/components/<name>/` (or the
|
||||
* configured `componentsDir`).
|
||||
* 2. Every public component directory under `src/components/`
|
||||
* that ships a `<name>.tsx` or `index.{ts,tsx}` SHALL have a
|
||||
* corresponding `registry/r/<name>.json`. Drift in either
|
||||
* direction (registry entry without source, or source without
|
||||
* registry entry) fails the gate.
|
||||
* 3. Every entry SHALL declare a stable `name` and `type` per the
|
||||
* shadcn schema and SHALL parse as JSON.
|
||||
*
|
||||
* NOT in scope today (deferred until the registry transform itself
|
||||
* lands as a callable helper — see `build-registry.ts` § "Future
|
||||
* Wave 3 enhancements"):
|
||||
*
|
||||
* - Applying the transform that rewrites `@suaveplan/react-ui`
|
||||
* imports to consumer-local relative paths.
|
||||
* - Running `tsc --noEmit` against the transformed tree under a
|
||||
* synthesized consumer `tsconfig.json` to verify the
|
||||
* `@suaveplan/react-i18n` import contract survives ejection.
|
||||
*
|
||||
* Those gates will land alongside the transform itself; until then,
|
||||
* `bun run typecheck` over the in-source files is the strongest
|
||||
* signal we have that an ejected component would compile (the
|
||||
* registry ships source verbatim today; the transform is a no-op).
|
||||
*
|
||||
* Wired into `bunx turbo lint`. Exits non-zero on any diagnostic.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PACKAGE_ROOT = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const REGISTRY_DIR = join(PACKAGE_ROOT, "registry", "r");
|
||||
const COMPONENTS_DIR = join(PACKAGE_ROOT, "src", "components");
|
||||
|
||||
export interface RegistryFile {
|
||||
readonly path: string;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export interface RegistryEntry {
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
readonly files: ReadonlyArray<RegistryFile>;
|
||||
readonly registryDependencies?: ReadonlyArray<string>;
|
||||
readonly dependencies?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export type DiagnosticKind =
|
||||
| "MISSING_REGISTRY_ENTRY"
|
||||
| "MISSING_SOURCE_FILE"
|
||||
| "MALFORMED_ENTRY"
|
||||
| "EMPTY_FILES_LIST"
|
||||
| "ORPHAN_REGISTRY_ENTRY";
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly kind: DiagnosticKind;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
async function listRegistryEntryFiles(): Promise<string[]> {
|
||||
if (!existsSync(REGISTRY_DIR)) return [];
|
||||
const entries = await readdir(REGISTRY_DIR, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isFile() &&
|
||||
e.name.endsWith(".json") &&
|
||||
e.name !== "index.json"
|
||||
)
|
||||
.map((e) => join(REGISTRY_DIR, e.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function listComponentDirectories(): Promise<string[]> {
|
||||
if (!existsSync(COMPONENTS_DIR)) return [];
|
||||
const entries = await readdir(COMPONENTS_DIR, { withFileTypes: true });
|
||||
const out: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const sub = join(COMPONENTS_DIR, entry.name);
|
||||
const hasIndex =
|
||||
existsSync(join(sub, "index.ts")) ||
|
||||
existsSync(join(sub, "index.tsx")) ||
|
||||
existsSync(join(sub, `${entry.name}.tsx`));
|
||||
if (hasIndex) out.push(entry.name);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
export async function loadEntry(
|
||||
entryPath: string
|
||||
): Promise<RegistryEntry | { readonly error: string }> {
|
||||
try {
|
||||
const raw = await readFile(entryPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<RegistryEntry>;
|
||||
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
||||
return { error: "missing or empty 'name'" };
|
||||
}
|
||||
if (typeof parsed.type !== "string") {
|
||||
return { error: "missing 'type'" };
|
||||
}
|
||||
if (!Array.isArray(parsed.files)) {
|
||||
return { error: "missing 'files' array" };
|
||||
}
|
||||
for (const f of parsed.files) {
|
||||
if (
|
||||
typeof f !== "object" ||
|
||||
f === null ||
|
||||
typeof (f as RegistryFile).path !== "string"
|
||||
) {
|
||||
return { error: "files[] entry missing 'path'" };
|
||||
}
|
||||
}
|
||||
return parsed as RegistryEntry;
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditOptions {
|
||||
readonly registryEntries: ReadonlyArray<string>;
|
||||
readonly componentDirs: ReadonlyArray<string>;
|
||||
readonly componentsDir: string;
|
||||
}
|
||||
|
||||
export async function audit(opts: AuditOptions): Promise<Diagnostic[]> {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const entryNames = new Set<string>();
|
||||
|
||||
for (const entryPath of opts.registryEntries) {
|
||||
const entryRel = relative(PACKAGE_ROOT, entryPath);
|
||||
const loaded = await loadEntry(entryPath);
|
||||
if ("error" in loaded) {
|
||||
diagnostics.push({
|
||||
kind: "MALFORMED_ENTRY",
|
||||
message: `${entryRel}: ${loaded.error}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const entryBase = entryPath.replace(/\.json$/, "").split("/").pop()!;
|
||||
entryNames.add(entryBase);
|
||||
if (loaded.files.length === 0) {
|
||||
diagnostics.push({
|
||||
kind: "EMPTY_FILES_LIST",
|
||||
message: `${entryRel}: files[] is empty`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const file of loaded.files) {
|
||||
const sourcePath = join(opts.componentsDir, entryBase, file.path);
|
||||
if (!existsSync(sourcePath)) {
|
||||
diagnostics.push({
|
||||
kind: "MISSING_SOURCE_FILE",
|
||||
message: `${entryRel}: file '${file.path}' does not exist at ${relative(PACKAGE_ROOT, sourcePath)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of opts.componentDirs) {
|
||||
if (!entryNames.has(dir)) {
|
||||
diagnostics.push({
|
||||
kind: "MISSING_REGISTRY_ENTRY",
|
||||
message: `src/components/${dir}/ has no registry entry at registry/r/${dir}.json`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of entryNames) {
|
||||
if (!opts.componentDirs.includes(entryName)) {
|
||||
diagnostics.push({
|
||||
kind: "ORPHAN_REGISTRY_ENTRY",
|
||||
message: `registry/r/${entryName}.json has no matching src/components/${entryName}/`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.message < b.message ? -1 : a.message > b.message ? 1 : 0;
|
||||
});
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const registryEntries = await listRegistryEntryFiles();
|
||||
const componentDirs = await listComponentDirectories();
|
||||
|
||||
if (registryEntries.length === 0) {
|
||||
console.error(
|
||||
`check-registry-eject: no registry entries found under ${relative(PACKAGE_ROOT, REGISTRY_DIR)} — run 'bun scripts/build-registry.ts' first.`
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
if (componentDirs.length === 0) {
|
||||
console.error(
|
||||
`check-registry-eject: no component directories found under ${relative(PACKAGE_ROOT, COMPONENTS_DIR)}`
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const diagnostics = await audit({
|
||||
registryEntries,
|
||||
componentDirs,
|
||||
componentsDir: COMPONENTS_DIR,
|
||||
});
|
||||
|
||||
if (diagnostics.length === 0) {
|
||||
console.log(
|
||||
`check-registry-eject: clean — ${registryEntries.length} registry entries match ${componentDirs.length} component directories.`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const d of diagnostics) {
|
||||
console.error(`${d.kind} ${d.message}`);
|
||||
}
|
||||
console.error(
|
||||
`check-registry-eject: ${diagnostics.length} diagnostic(s) — see above.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
process.exit(await main());
|
||||
}
|
||||
Reference in New Issue
Block a user