diff --git a/packages/mcp/src/tools/compose.ts b/packages/mcp/src/tools/compose.ts new file mode 100644 index 0000000..f78b7e8 --- /dev/null +++ b/packages/mcp/src/tools/compose.ts @@ -0,0 +1,53 @@ +import type { ComponentRegistry } from "../registry.js"; + +interface ComposeInput { components: string[]; } +interface ComposeResult { jsx: string; imports: string[]; warnings: string[]; } + +function toKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +function toPascal(kebab: string): string { + return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(""); +} + +function generateComponentJsx(name: string, parts: readonly string[], requiredParts: readonly string[], indent: number): string { + const pad = " ".repeat(indent); + const lines: string[] = []; + if (parts.includes("Root")) { + lines.push(`${pad}<${name}>`); + for (const part of requiredParts) { + if (part === "Root") continue; + lines.push(`${pad} <${name}.${part}>...`); + } + lines.push(`${pad}`); + } else { + lines.push(`${pad}<${name} />`); + } + return lines.join("\n"); +} + +/** Converts a pettyui/ export path to an ES import statement. */ +function toImportLine(imp: string): string { + const name = toPascal(imp.replace("pettyui/", "")); + return `import { ${name} } from "${imp}";`; +} + +/** Generates composed JSX from multiple components. */ +export function handleCompose(registry: ComponentRegistry, input: ComposeInput): ComposeResult { + const imports: string[] = []; + const jsxParts: string[] = []; + const warnings: string[] = []; + + for (const name of input.components) { + const comp = registry.getComponent(name); + if (!comp) { warnings.push(`Component "${name}" not found`); continue; } + imports.push(comp.exportPath); + jsxParts.push(generateComponentJsx(comp.meta.name, comp.meta.parts, comp.meta.requiredParts, 1)); + } + + const importLines = imports.map(toImportLine); + const jsx = [...importLines, "", "function Component() {", " return (", " <>", jsxParts.join("\n"), " ", " );", "}"].join("\n"); + + return { jsx, imports, warnings }; +} diff --git a/packages/mcp/tests/tools/compose.test.ts b/packages/mcp/tests/tools/compose.test.ts new file mode 100644 index 0000000..4d38f87 --- /dev/null +++ b/packages/mcp/tests/tools/compose.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { handleCompose } from "../../src/tools/compose.js"; +import { ComponentRegistry } from "../../src/registry.js"; + +describe("pettyui.compose", () => { + const registry = new ComponentRegistry(); + + it("generates JSX for specified components — Dialog + Button both present", () => { + const result = handleCompose(registry, { components: ["Dialog", "Button"] }); + expect(result.jsx).toContain("Dialog"); + expect(result.jsx).toContain("Button"); + expect(result.imports).toContain("pettyui/dialog"); + expect(result.imports).toContain("pettyui/button"); + }); + + it("generates imports for all — Form + TextField + Button → 3 imports", () => { + const result = handleCompose(registry, { components: ["Form", "TextField", "Button"] }); + expect(result.imports).toHaveLength(3); + }); + + it("uses required parts — Dialog → jsx contains Dialog.Content and Dialog.Title", () => { + const result = handleCompose(registry, { components: ["Dialog"] }); + expect(result.jsx).toContain("Dialog.Content"); + expect(result.jsx).toContain("Dialog.Title"); + }); + + it("skips unknown with warning — FakeComponent gets warning, Dialog still in jsx", () => { + const result = handleCompose(registry, { components: ["Dialog", "FakeComponent"] }); + expect(result.warnings).toContain('Component "FakeComponent" not found'); + expect(result.jsx).toContain("Dialog"); + expect(result.imports).toHaveLength(1); + }); +});