MCP compose tool

This commit is contained in:
Mats Bosson 2026-03-29 23:50:55 +07:00
parent 6545b53310
commit 0e5033414b
2 changed files with 86 additions and 0 deletions

View File

@ -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}>...</${name}.${part}>`);
}
lines.push(`${pad}</${name}>`);
} 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 };
}

View File

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