MCP compose tool
This commit is contained in:
parent
6545b53310
commit
0e5033414b
53
packages/mcp/src/tools/compose.ts
Normal file
53
packages/mcp/src/tools/compose.ts
Normal 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 };
|
||||||
|
}
|
||||||
33
packages/mcp/tests/tools/compose.test.ts
Normal file
33
packages/mcp/tests/tools/compose.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user