diff --git a/corpus/frontend/shadcn/button.yaml b/corpus/frontend/shadcn/button.yaml new file mode 100644 index 0000000..ef71039 --- /dev/null +++ b/corpus/frontend/shadcn/button.yaml @@ -0,0 +1,35 @@ +name: Button +category: button +description: Interactive button with variant + size support. Uses CVA for variant composition. +basePrimitive: native + + // With variants and sizes + + + + // Icon usage (data-slot automatic in component) + +pairsWith: + - Dialog + - Form + - DropdownMenu diff --git a/corpus/frontend/shadcn/dialog.yaml b/corpus/frontend/shadcn/dialog.yaml new file mode 100644 index 0000000..d952c95 --- /dev/null +++ b/corpus/frontend/shadcn/dialog.yaml @@ -0,0 +1,33 @@ +name: Dialog +category: dialog +description: Modal dialog with backdrop and portal. Uses @base-ui/react Dialog primitive. +basePrimitive: "@base-ui/react/dialog" +dataSlots: + - dialog-overlay + - dialog-content + - dialog-header + - dialog-title + - dialog-description + - dialog-footer +variants: [] +sizes: [] +requiresUseClient: true +usageSnippet: | + + }>Open Dialog + + + Are you sure? + + This action cannot be undone. + + + + + + + +pairsWith: + - Button + - Form + - Select diff --git a/corpus/frontend/shadcn/field.yaml b/corpus/frontend/shadcn/field.yaml new file mode 100644 index 0000000..e212045 --- /dev/null +++ b/corpus/frontend/shadcn/field.yaml @@ -0,0 +1,37 @@ +name: Field +category: form +description: Form field wrapper with label, description, error. Uses @container for responsive layout. +basePrimitive: "@base-ui/react/field" +dataSlots: + - field + - field-label + - field-control + - field-description + - field-error +variants: + - vertical + - horizontal +sizes: [] +requiresUseClient: false +usageSnippet: | + + Username + + + This is your public display name. + + + + + // Horizontal orientation with Container Queries + + + Email + + + +pairsWith: + - Input + - Select + - Textarea + - Checkbox diff --git a/corpus/frontend/shadcn/index.yaml b/corpus/frontend/shadcn/index.yaml new file mode 100644 index 0000000..ba4ba60 --- /dev/null +++ b/corpus/frontend/shadcn/index.yaml @@ -0,0 +1,10 @@ +namespace: frontend.shadcn +components: + button: + file: button.yaml + dialog: + file: dialog.yaml + field: + file: field.yaml + select: + file: select.yaml diff --git a/corpus/frontend/shadcn/select.yaml b/corpus/frontend/shadcn/select.yaml new file mode 100644 index 0000000..aecb292 --- /dev/null +++ b/corpus/frontend/shadcn/select.yaml @@ -0,0 +1,33 @@ +name: Select +category: dropdown +description: Accessible select with search and keyboard nav. Uses @base-ui/react Select. +basePrimitive: "@base-ui/react/select" +dataSlots: + - select-trigger + - select-value + - select-content + - select-item + - select-icon +variants: [] +sizes: + - sm + - md + - lg +requiresUseClient: true +usageSnippet: | + +pairsWith: + - Field + - Form diff --git a/corpus/index.yaml b/corpus/index.yaml index 888611c..2f1978e 100644 --- a/corpus/index.yaml +++ b/corpus/index.yaml @@ -9,3 +9,5 @@ namespaces: index: frontend/designer/index.yaml frontend.design-tokens: index: frontend/design-tokens/index.yaml + frontend.shadcn: + index: frontend/shadcn/index.yaml diff --git a/src/plugins/shadcn/tools/get-component.ts b/src/plugins/shadcn/tools/get-component.ts index c68ce8a..e21761f 100644 --- a/src/plugins/shadcn/tools/get-component.ts +++ b/src/plugins/shadcn/tools/get-component.ts @@ -1,6 +1,111 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; import { z } from "zod"; -import { getComponentByName, SHADCN_COMPONENTS } from "../data.js"; +import { getComponentByName, SHADCN_COMPONENTS, type ShadcnComponent } from "../data.js"; + +type CorpusIndex = { + namespaces?: { + "frontend.shadcn"?: { + index?: string; + }; + }; +}; + +type CorpusNamespaceIndex = { + namespace?: string; + components?: Record; +}; + +type CorpusComponentEntry = { + name?: string; + category?: string; + description?: string; + basePrimitive?: string; + dataSlots?: string[]; + variants?: string[]; + sizes?: string[]; + requiresUseClient?: boolean; + usageSnippet?: string; + pairsWith?: string[]; +}; + +type LoadedCorpusComponent = { component: ShadcnComponent; source: string }; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = join(moduleDir, "../../../../corpus"); +const corpusNamespace = "frontend.shadcn"; + +const cachedCorpusComponents = new Map(); + +function normalize(name: string): string { + return name.toLowerCase().trim(); +} + +function loadCorpusComponent(name: string): LoadedCorpusComponent | null { + const key = normalize(name); + if (cachedCorpusComponents.has(key)) { + return cachedCorpusComponents.get(key) ?? null; + } + + try { + const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8"); + const index = YAML.parse(indexRaw) as CorpusIndex | null; + const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; + if (!namespaceIndexPath) { + cachedCorpusComponents.set(key, null); + return null; + } + + const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); + const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; + const componentPath = namespaceIndex?.components?.[key]?.file; + if (!componentPath) { + cachedCorpusComponents.set(key, null); + return null; + } + + const raw = readFileSync(join(corpusRoot, "frontend/shadcn", componentPath), "utf8"); + const entry = YAML.parse(raw) as CorpusComponentEntry | null; + if ( + !entry || + normalize(entry.name ?? "") !== key || + !entry.category || + !entry.description || + !entry.basePrimitive || + !Array.isArray(entry.dataSlots) || + !entry.usageSnippet || + typeof entry.requiresUseClient !== "boolean" || + !Array.isArray(entry.pairsWith) + ) { + cachedCorpusComponents.set(key, null); + return null; + } + + const loaded: LoadedCorpusComponent = { + component: { + name: entry.name ?? name, + category: entry.category as ShadcnComponent["category"], + description: entry.description, + basePrimitive: entry.basePrimitive, + dataSlots: entry.dataSlots, + variants: entry.variants, + sizes: entry.sizes, + requiresUseClient: entry.requiresUseClient, + usageSnippet: entry.usageSnippet, + pairsWith: entry.pairsWith, + }, + source: corpusNamespace, + }; + cachedCorpusComponents.set(key, loaded); + return loaded; + } catch { + cachedCorpusComponents.set(key, null); + return null; + } +} export function register(server: McpServer): void { server.tool( @@ -10,7 +115,8 @@ export function register(server: McpServer): void { name: z.string().describe("Component name (e.g., 'Button', 'Dialog', 'Field', 'Select'). Case-insensitive."), }, async ({ name }) => { - const component = getComponentByName(name); + const corpusEntry = loadCorpusComponent(name); + const component = corpusEntry?.component ?? getComponentByName(name); if (!component) { const available = SHADCN_COMPONENTS.map((c) => c.name).join(", "); return { @@ -58,6 +164,10 @@ export function register(server: McpServer): void { text += `- [ ] Props spread (...props) to underlying primitive\n`; text += `- [ ] OKLCH color tokens from design system (not hardcoded hex)\n`; + if (corpusEntry) { + text += `\n**Corpus Source:** ${corpusEntry.source}`; + } + return { content: [{ type: "text" as const, text }] }; } ); diff --git a/tests/shadcn-component-corpus-backed-tools-behaviour.test.ts b/tests/shadcn-component-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..42815ff --- /dev/null +++ b/tests/shadcn-component-corpus-backed-tools-behaviour.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { captureTool, extractTextContent } from "./helpers"; +import { register as registerShadcnGetComponent } from "../src/plugins/shadcn/tools/get-component.ts"; + +const shadcnGetComponent = captureTool(registerShadcnGetComponent); + +test("shadcn_get_component prefers corpus metadata for Button", async () => { + const result = await shadcnGetComponent.invoke({ name: "Button" }); + const text = extractTextContent(result); + + expect(text).toContain("# Button"); + expect(text).toContain("**Corpus Source:** frontend.shadcn"); + expect(text).toContain('