MCP component registry
This commit is contained in:
parent
2989ce4974
commit
9f78750105
30
packages/mcp/src/component-imports.ts
Normal file
30
packages/mcp/src/component-imports.ts
Normal 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";
|
||||||
172
packages/mcp/src/registry.ts
Normal file
172
packages/mcp/src/registry.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
packages/mcp/tests/registry.test.ts
Normal file
58
packages/mcp/tests/registry.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user