MCP component registry

This commit is contained in:
Mats Bosson 2026-03-29 23:48:03 +07:00
parent 2989ce4974
commit 9f78750105
3 changed files with 260 additions and 0 deletions

View File

@ -0,0 +1,30 @@
export interface ComponentMeta {
readonly name: string;
readonly description: string;
readonly parts: readonly string[];
readonly requiredParts: readonly string[];
}
export interface ComponentEntry {
exportPath: string;
meta: ComponentMeta;
schemas: Record<string, unknown>;
}
/** Converts a PascalCase component name to its pettyui/ kebab-case export path. */
export function toExportPath(name: string): string {
return "pettyui/" + name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
/** Looks up a single entry by component name (case-insensitive). Returns undefined if not found. */
export function findEntry(name: string, entries: ComponentEntry[]): ComponentEntry | undefined {
const lower = name.toLowerCase();
return entries.find((e) => e.meta.name.toLowerCase() === lower);
}
/** Returns all registered pettyui/ export paths from a given entry list. */
export function allExportPaths(entries: ComponentEntry[]): string[] {
return entries.map((e) => e.exportPath);
}
export { COMPONENT_ENTRIES } from "./registry.js";

View File

@ -0,0 +1,172 @@
import { z } from "zod/v4";
import { accessSync } from "node:fs";
import { resolve, join } from "node:path";
import { type ComponentEntry, type ComponentMeta, toExportPath } from "./component-imports.js";
import { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "../../core/src/components/dialog/dialog.props.js";
import { ButtonPropsSchema, ButtonMeta } from "../../core/src/components/button/button.props.js";
import { BadgePropsSchema, BadgeMeta } from "../../core/src/components/badge/badge.props.js";
import { AlertPropsSchema, AlertMeta } from "../../core/src/components/alert/alert.props.js";
import { LinkPropsSchema, LinkMeta } from "../../core/src/components/link/link.props.js";
import { SelectRootPropsSchema, SelectItemPropsSchema, SelectMeta } from "../../core/src/components/select/select.props.js";
import { TogglePropsSchema, ToggleMeta } from "../../core/src/components/toggle/toggle.props.js";
import { ComboboxRootPropsSchema, ComboboxItemPropsSchema, ComboboxMeta } from "../../core/src/components/combobox/combobox.props.js";
import { DropdownMenuRootPropsSchema, DropdownMenuItemPropsSchema, DropdownMenuMeta } from "../../core/src/components/dropdown-menu/dropdown-menu.props.js";
import { TextFieldRootPropsSchema, TextFieldMeta } from "../../core/src/components/text-field/text-field.props.js";
import { ListboxRootPropsSchema, ListboxItemPropsSchema, ListboxMeta } from "../../core/src/components/listbox/listbox.props.js";
import { CheckboxRootPropsSchema, CheckboxMeta } from "../../core/src/components/checkbox/checkbox.props.js";
import { SwitchRootPropsSchema, SwitchMeta } from "../../core/src/components/switch/switch.props.js";
import { TooltipRootPropsSchema, TooltipMeta } from "../../core/src/components/tooltip/tooltip.props.js";
import { PopoverRootPropsSchema, PopoverMeta } from "../../core/src/components/popover/popover.props.js";
import { RadioGroupRootPropsSchema, RadioGroupItemPropsSchema, RadioGroupMeta } from "../../core/src/components/radio-group/radio-group.props.js";
import { HoverCardRootPropsSchema, HoverCardMeta } from "../../core/src/components/hover-card/hover-card.props.js";
import { DrawerRootPropsSchema, DrawerMeta } from "../../core/src/components/drawer/drawer.props.js";
import { ToastRegionPropsSchema, ToastMeta } from "../../core/src/components/toast/toast.props.js";
import { SkeletonPropsSchema, SkeletonMeta } from "../../core/src/components/skeleton/skeleton.props.js";
import { SliderRootPropsSchema, SliderMeta } from "../../core/src/components/slider/slider.props.js";
import { NumberFieldRootPropsSchema as NumFieldRootSchema, NumberFieldMeta as NumFieldMeta } from "../../core/src/components/number-field/number-field.props.js";
import { ToggleGroupRootPropsSchema, ToggleGroupItemPropsSchema, ToggleGroupMeta } from "../../core/src/components/toggle-group/toggle-group.props.js";
import { ProgressRootPropsSchema, ProgressMeta } from "../../core/src/components/progress/progress.props.js";
import { CollapsibleRootPropsSchema, CollapsibleMeta } from "../../core/src/components/collapsible/collapsible.props.js";
import { AlertDialogRootPropsSchema, AlertDialogMeta } from "../../core/src/components/alert-dialog/alert-dialog.props.js";
import { BreadcrumbsRootPropsSchema, BreadcrumbsMeta } from "../../core/src/components/breadcrumbs/breadcrumbs.props.js";
import { TabsRootPropsSchema, TabsMeta } from "../../core/src/components/tabs/tabs.props.js";
import { AccordionRootPropsSchema, AccordionItemPropsSchema, AccordionMeta } from "../../core/src/components/accordion/accordion.props.js";
import { CardPropsSchema, CardMeta } from "../../core/src/components/card/card.props.js";
import { AvatarRootPropsSchema, AvatarImagePropsSchema, AvatarMeta } from "../../core/src/components/avatar/avatar.props.js";
import { NavigationMenuRootPropsSchema, NavigationMenuMeta } from "../../core/src/components/navigation-menu/navigation-menu.props.js";
import { CommandPaletteRootPropsSchema, CommandPaletteMeta } from "../../core/src/components/command-palette/command-palette.props.js";
import { FormRootPropsSchema, FormFieldPropsSchema, FormMeta } from "../../core/src/components/form/form.props.js";
import { CalendarRootPropsSchema, CalendarMeta } from "../../core/src/components/calendar/calendar.props.js";
import { WizardRootPropsSchema, WizardStepPropsSchema, WizardMeta } from "../../core/src/components/wizard/wizard.props.js";
import { DataTableRootPropsSchema, DataTableMeta } from "../../core/src/components/data-table/data-table.props.js";
import { VirtualListRootPropsSchema, VirtualListMeta } from "../../core/src/components/virtual-list/virtual-list.props.js";
import { DatePickerRootPropsSchema, DatePickerMeta } from "../../core/src/components/date-picker/date-picker.props.js";
import { SeparatorPropsSchema, SeparatorMeta } from "../../core/src/components/separator/separator.props.js";
import { PaginationRootPropsSchema, PaginationMeta } from "../../core/src/components/pagination/pagination.props.js";
export interface RegisteredComponent { meta: ComponentMeta; schemas: Record<string, unknown>; jsonSchemas: Record<string, unknown>; hasStyledVersion: boolean; exportPath: string; }
export interface SearchResult extends RegisteredComponent { score: number; }
function buildEntries(): ComponentEntry[] {
const ep = toExportPath;
const out: ComponentEntry[] = [];
out[out.length] = { exportPath: ep("Dialog"), meta: DialogMeta, schemas: { dialogRoot: DialogRootPropsSchema, dialogContent: DialogContentPropsSchema } };
out[out.length] = { exportPath: ep("Button"), meta: ButtonMeta, schemas: { button: ButtonPropsSchema } };
out[out.length] = { exportPath: ep("Badge"), meta: BadgeMeta, schemas: { badge: BadgePropsSchema } };
out[out.length] = { exportPath: ep("Alert"), meta: AlertMeta, schemas: { alert: AlertPropsSchema } };
out[out.length] = { exportPath: ep("Link"), meta: LinkMeta, schemas: { link: LinkPropsSchema } };
out[out.length] = { exportPath: ep("Select"), meta: SelectMeta, schemas: { selectRoot: SelectRootPropsSchema, selectItem: SelectItemPropsSchema } };
out[out.length] = { exportPath: ep("Toggle"), meta: ToggleMeta, schemas: { toggle: TogglePropsSchema } };
out[out.length] = { exportPath: ep("Combobox"), meta: ComboboxMeta, schemas: { comboboxRoot: ComboboxRootPropsSchema, comboboxItem: ComboboxItemPropsSchema } };
out[out.length] = { exportPath: ep("DropdownMenu"), meta: DropdownMenuMeta, schemas: { dropdownMenuRoot: DropdownMenuRootPropsSchema, dropdownMenuItem: DropdownMenuItemPropsSchema } };
out[out.length] = { exportPath: ep("TextField"), meta: TextFieldMeta, schemas: { textFieldRoot: TextFieldRootPropsSchema } };
out[out.length] = { exportPath: ep("Listbox"), meta: ListboxMeta, schemas: { listboxRoot: ListboxRootPropsSchema, listboxItem: ListboxItemPropsSchema } };
out[out.length] = { exportPath: ep("Checkbox"), meta: CheckboxMeta, schemas: { checkboxRoot: CheckboxRootPropsSchema } };
out[out.length] = { exportPath: ep("Switch"), meta: SwitchMeta, schemas: { switchRoot: SwitchRootPropsSchema } };
out[out.length] = { exportPath: ep("Tooltip"), meta: TooltipMeta, schemas: { tooltipRoot: TooltipRootPropsSchema } };
out[out.length] = { exportPath: ep("Popover"), meta: PopoverMeta, schemas: { popoverRoot: PopoverRootPropsSchema } };
out[out.length] = { exportPath: ep("RadioGroup"), meta: RadioGroupMeta, schemas: { radioGroupRoot: RadioGroupRootPropsSchema, radioGroupItem: RadioGroupItemPropsSchema } };
out[out.length] = { exportPath: ep("HoverCard"), meta: HoverCardMeta, schemas: { hoverCardRoot: HoverCardRootPropsSchema } };
out[out.length] = { exportPath: ep("Drawer"), meta: DrawerMeta, schemas: { drawerRoot: DrawerRootPropsSchema } };
out[out.length] = { exportPath: ep("Toast"), meta: ToastMeta, schemas: { toastRegion: ToastRegionPropsSchema } };
out[out.length] = { exportPath: ep("Skeleton"), meta: SkeletonMeta, schemas: { skeleton: SkeletonPropsSchema } };
out[out.length] = { exportPath: ep("Slider"), meta: SliderMeta, schemas: { sliderRoot: SliderRootPropsSchema } };
out[out.length] = { exportPath: ep("NumField"), meta: NumFieldMeta, schemas: { numFieldRoot: NumFieldRootSchema } };
out[out.length] = { exportPath: ep("ToggleGroup"), meta: ToggleGroupMeta, schemas: { toggleGroupRoot: ToggleGroupRootPropsSchema, toggleGroupItem: ToggleGroupItemPropsSchema } };
out[out.length] = { exportPath: ep("Progress"), meta: ProgressMeta, schemas: { progressRoot: ProgressRootPropsSchema } };
out[out.length] = { exportPath: ep("Collapsible"), meta: CollapsibleMeta, schemas: { collapsibleRoot: CollapsibleRootPropsSchema } };
out[out.length] = { exportPath: ep("AlertDialog"), meta: AlertDialogMeta, schemas: { alertDialogRoot: AlertDialogRootPropsSchema } };
out[out.length] = { exportPath: ep("Breadcrumbs"), meta: BreadcrumbsMeta, schemas: { breadcrumbsRoot: BreadcrumbsRootPropsSchema } };
out[out.length] = { exportPath: ep("Tabs"), meta: TabsMeta, schemas: { tabsRoot: TabsRootPropsSchema } };
out[out.length] = { exportPath: ep("Accordion"), meta: AccordionMeta, schemas: { accordionRoot: AccordionRootPropsSchema, accordionItem: AccordionItemPropsSchema } };
out[out.length] = { exportPath: ep("Card"), meta: CardMeta, schemas: { card: CardPropsSchema } };
out[out.length] = { exportPath: ep("Avatar"), meta: AvatarMeta, schemas: { avatarRoot: AvatarRootPropsSchema, avatarImage: AvatarImagePropsSchema } };
out[out.length] = { exportPath: ep("NavigationMenu"), meta: NavigationMenuMeta, schemas: { navigationMenuRoot: NavigationMenuRootPropsSchema } };
out[out.length] = { exportPath: ep("CommandPalette"), meta: CommandPaletteMeta, schemas: { commandPaletteRoot: CommandPaletteRootPropsSchema } };
out[out.length] = { exportPath: ep("Form"), meta: FormMeta, schemas: { formRoot: FormRootPropsSchema, formField: FormFieldPropsSchema } };
out[out.length] = { exportPath: ep("Calendar"), meta: CalendarMeta, schemas: { calendarRoot: CalendarRootPropsSchema } };
out[out.length] = { exportPath: ep("Wizard"), meta: WizardMeta, schemas: { wizardRoot: WizardRootPropsSchema, wizardStep: WizardStepPropsSchema } };
out[out.length] = { exportPath: ep("DataTable"), meta: DataTableMeta, schemas: { dataTableRoot: DataTableRootPropsSchema } };
out[out.length] = { exportPath: ep("VirtualList"), meta: VirtualListMeta, schemas: { virtualListRoot: VirtualListRootPropsSchema } };
out[out.length] = { exportPath: ep("DatePicker"), meta: DatePickerMeta, schemas: { datePickerRoot: DatePickerRootPropsSchema } };
out[out.length] = { exportPath: ep("Separator"), meta: SeparatorMeta, schemas: { separator: SeparatorPropsSchema } };
out[out.length] = { exportPath: ep("Pagination"), meta: PaginationMeta, schemas: { paginationRoot: PaginationRootPropsSchema } };
return out;
}
export const COMPONENT_ENTRIES: ComponentEntry[] = buildEntries();
function toJsonSchema(schema: unknown): unknown {
try {
return z.toJsonSchema(schema as z.ZodType);
} catch {
return { type: "object" };
}
}
function styledFileExists(name: string): boolean {
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
const styledPath = join(resolve(import.meta.dirname, "../../registry/src/components"), `${kebab}.tsx`);
try {
accessSync(styledPath);
return true;
} catch {
return false;
}
}
function toRegistered(entry: ComponentEntry): RegisteredComponent {
const jsonSchemas: Record<string, unknown> = {};
for (const [key, schema] of Object.entries(entry.schemas)) {
jsonSchemas[key] = toJsonSchema(schema);
}
return { meta: entry.meta, schemas: entry.schemas, jsonSchemas, hasStyledVersion: styledFileExists(entry.meta.name), exportPath: entry.exportPath };
}
/** Registry of all PettyUI components with search, lookup, and JSON Schema conversion. */
export class ComponentRegistry {
private readonly components: Map<string, RegisteredComponent>;
constructor() {
this.components = new Map();
this.loadComponents();
}
private loadComponents(): void {
for (const entry of COMPONENT_ENTRIES) {
this.components.set(entry.meta.name.toLowerCase(), toRegistered(entry));
}
}
/** Returns all registered components. */
listComponents(): RegisteredComponent[] {
return Array.from(this.components.values());
}
/** Finds a component by name (case-insensitive). Returns undefined if not found. */
getComponent(name: string): RegisteredComponent | undefined {
return this.components.get(name.toLowerCase());
}
/** Searches components by name and description, returning ranked results. */
search(query: string): SearchResult[] {
const terms = query.toLowerCase().split(/\s+/);
const results: SearchResult[] = [];
for (const comp of this.components.values()) {
const score = this.scoreComponent(comp, terms);
if (score > 0) results.push({ ...comp, score });
}
return results.sort((a, b) => b.score - a.score);
}
private scoreComponent(comp: RegisteredComponent, terms: string[]): number {
const name = comp.meta.name.toLowerCase();
const text = `${name} ${comp.meta.description.toLowerCase()}`;
let score = 0;
for (const term of terms) {
if (text.includes(term)) score += 1;
if (name === term) score += 3;
if (name.includes(term)) score += 1;
}
return score;
}
}

View File

@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { ComponentRegistry } from "../src/registry.js";
describe("ComponentRegistry", () => {
const registry = new ComponentRegistry();
it("loads all components", () => {
const all = registry.listComponents();
expect(all.length).toBeGreaterThan(30);
});
it("finds by exact name", () => {
const comp = registry.getComponent("Dialog");
expect(comp).toBeDefined();
expect(comp?.meta.name).toBe("Dialog");
});
it("finds case-insensitive", () => {
const lower = registry.getComponent("dialog");
const upper = registry.getComponent("DIALOG");
expect(lower).toBeDefined();
expect(upper).toBeDefined();
expect(lower?.meta.name).toBe("Dialog");
});
it("returns undefined for unknown component", () => {
const comp = registry.getComponent("NonExistentWidget");
expect(comp).toBeUndefined();
});
it("searches by description keyword", () => {
const results = registry.search("modal overlay");
expect(results.length).toBeGreaterThan(0);
const names = results.map((r) => r.meta.name);
expect(names).toContain("Dialog");
});
it("searches by intent — searchable dropdown finds Combobox", () => {
const results = registry.search("searchable dropdown");
expect(results.length).toBeGreaterThan(0);
const names = results.map((r) => r.meta.name);
expect(names).toContain("Combobox");
});
it("returns json schemas for dialog", () => {
const comp = registry.getComponent("dialog");
expect(comp).toBeDefined();
expect(comp?.jsonSchemas).toBeDefined();
expect(Object.keys(comp?.jsonSchemas ?? {}).length).toBeGreaterThan(0);
});
it("reports hasStyledVersion correctly for dialog", () => {
const comp = registry.getComponent("dialog");
expect(comp).toBeDefined();
expect(typeof comp?.hasStyledVersion).toBe("boolean");
expect(comp?.hasStyledVersion).toBe(true);
});
});