Skip to content

Commit cdea3a4

Browse files
authored
feat(mcp): auto-detect Vibe version for correct metadata (#3393)
1 parent 07e344c commit cdea3a4

15 files changed

Lines changed: 620 additions & 353 deletions

packages/core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"build": "yarn build:esm && yarn build:mocked-classnames && yarn build:metadata",
8181
"build:esm": "rollup -c",
8282
"build:mocked-classnames": "[ \"$SKIP_RELEASE_ARTIFACTS\" = \"true\" ] || mock_classnames=on rollup -c",
83-
"build:metadata": "[ \"$SKIP_RELEASE_ARTIFACTS\" = \"true\" ] || tsx src/scripts/generate-metadata.ts",
83+
"build:metadata": "[ \"$SKIP_RELEASE_ARTIFACTS\" = \"true\" ] || tsx src/scripts/build-all-metadata.ts",
8484
"link-local": "npm link && npm start",
8585
"plop": "plop",
8686
"lint": "eslint \"./src/**/*.{js,jsx,ts,tsx}\"",
@@ -123,7 +123,9 @@
123123
"devDependencies": {
124124
"@babel/core": "^7.23.2",
125125
"@babel/eslint-parser": "^7.16.5",
126+
"@babel/generator": "^7.24.4",
126127
"@babel/parser": "^7.24.4",
128+
"@babel/traverse": "^7.24.4",
127129
"@babel/plugin-proposal-class-properties": "^7.16.5",
128130
"@babel/plugin-proposal-private-methods": "^7.18.6",
129131
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",

packages/mcp/scripts/__tests__/extract-accessibility.test.js renamed to packages/core/src/scripts/__tests__/extract-accessibility.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { describe, it, expect } from "vitest";
55
import { cleanUpAccessibilityContent, run } from "../extract-accessibility.js";
66

77
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8-
const outputDir = path.join(__dirname, "../../dist/generated/accessibility/");
8+
const outputDir = path.resolve(__dirname, "../../../dist/metadata/accessibility/");
99

1010
describe("cleanUpAccessibilityContent", () => {
1111
describe("UsageGuidelines extraction", () => {
@@ -17,9 +17,7 @@ describe("cleanUpAccessibilityContent", () => {
1717

1818
const result = cleanUpAccessibilityContent(content);
1919

20-
expect(result).toBe(
21-
"1. Use labels for all interactive elements\n2. Ensure color contrast meets WCAG AA"
22-
);
20+
expect(result).toBe("1. Use labels for all interactive elements\n2. Ensure color contrast meets WCAG AA");
2321
});
2422

2523
it("should extract guidelines wrapped in React fragments", () => {
@@ -114,7 +112,7 @@ describe("cleanUpAccessibilityContent", () => {
114112

115113
const result = cleanUpAccessibilityContent(content);
116114

117-
expect(result).not.toMatch(/^\s*[\[\]]\s*$/m);
115+
expect(result).not.toMatch(/^\s*[[\]]\s*$/m);
118116
expect(result).toContain("Valid content");
119117
expect(result).toContain("More valid content");
120118
});

packages/mcp/scripts/__tests__/extract-code-samples.test.js renamed to packages/core/src/scripts/__tests__/extract-examples.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import path from "path";
33
import { fileURLToPath } from "url";
44
import { describe, it, expect } from "vitest";
55
import parser from "@babel/parser";
6-
import { generateCodeForOneLiner, run } from "../extract-code-samples.js";
6+
import { generateCodeForOneLiner, run } from "../extract-examples.js";
7+
import type { File } from "@babel/types";
78

89
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9-
const outputDir = path.join(__dirname, "../../dist/generated/");
10+
const outputDir = path.resolve(__dirname, "../../../dist/metadata/examples/");
1011

11-
function parseCode(code) {
12+
function parseCode(code: string): File {
1213
return parser.parse(code, {
1314
sourceType: "module",
1415
plugins: ["typescript", "jsx"]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { execSync } from "child_process";
2+
import { fileURLToPath } from "url";
3+
import { resolve, dirname } from "path";
4+
5+
const coreRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
6+
7+
const scripts = [
8+
{ name: "components metadata", cmd: "tsx src/scripts/generate-metadata.ts" },
9+
{ name: "examples", cmd: "tsx src/scripts/extract-examples.ts" },
10+
{ name: "accessibility", cmd: "tsx src/scripts/extract-accessibility.ts" }
11+
];
12+
13+
for (const script of scripts) {
14+
console.log(`\n--- Building ${script.name} ---`);
15+
try {
16+
execSync(script.cmd, { cwd: coreRoot, stdio: "inherit" });
17+
} catch (error) {
18+
console.error(`Failed to build ${script.name}`);
19+
process.exit(1);
20+
}
21+
}
22+
23+
console.log("\nAll metadata builds complete");
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { fileURLToPath } from "url";
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
6+
const componentsDir = path.resolve(__dirname, "../../../docs/src/pages/components/");
7+
const outputDir = path.resolve(__dirname, "../../dist/metadata/accessibility/");
8+
9+
export function getMdxFiles(): string[] {
10+
const mdxFiles: string[] = [];
11+
12+
function traverseDirectory(dir: string): void {
13+
const entries = fs.readdirSync(dir, { withFileTypes: true });
14+
for (const entry of entries) {
15+
const fullPath = path.join(dir, entry.name);
16+
if (entry.isDirectory()) {
17+
traverseDirectory(fullPath);
18+
} else if (entry.isFile() && entry.name.endsWith(".mdx")) {
19+
mdxFiles.push(fullPath);
20+
}
21+
}
22+
}
23+
24+
traverseDirectory(componentsDir);
25+
return mdxFiles;
26+
}
27+
28+
export function extractAccessibilityFromMdx(file: string): void {
29+
const componentName = path.basename(file).split(".")[0];
30+
const outputFile = path.join(outputDir, componentName + ".md");
31+
const fileContent = fs.readFileSync(file, "utf-8");
32+
33+
const lines = fileContent.split("\n");
34+
const accessibilityStartIndex = lines.findIndex(line => line.trim() === "## Accessibility");
35+
36+
if (accessibilityStartIndex === -1) {
37+
return;
38+
}
39+
40+
let accessibilityEndIndex = lines.length;
41+
for (let i = accessibilityStartIndex + 1; i < lines.length; i++) {
42+
if (lines[i].trim().startsWith("##")) {
43+
accessibilityEndIndex = i;
44+
break;
45+
}
46+
}
47+
48+
const accessibilityLines = lines.slice(accessibilityStartIndex + 1, accessibilityEndIndex);
49+
50+
let startIndex = 0;
51+
let endIndex = accessibilityLines.length;
52+
53+
while (startIndex < accessibilityLines.length && accessibilityLines[startIndex].trim() === "") {
54+
startIndex++;
55+
}
56+
while (endIndex > startIndex && accessibilityLines[endIndex - 1].trim() === "") {
57+
endIndex--;
58+
}
59+
60+
const rawContent = accessibilityLines.slice(startIndex, endIndex).join("\n");
61+
const cleanContent = cleanUpAccessibilityContent(rawContent);
62+
const markdown = `# ${componentName} - Accessibility Requirements\n\n${cleanContent}`;
63+
64+
fs.writeFileSync(outputFile, markdown, "utf-8");
65+
}
66+
67+
export function cleanUpAccessibilityContent(content: string): string {
68+
const guidelinesMatch = content.match(/guidelines=\{(\[[\s\S]*?\])\}/);
69+
70+
if (guidelinesMatch) {
71+
const guidelinesArray = guidelinesMatch[1];
72+
const guidelines: string[] = [];
73+
const guidelineMatches = guidelinesArray.match(/(?:<>[\s\S]*?<\/>|"[^"]*")/g);
74+
75+
if (guidelineMatches) {
76+
guidelineMatches.forEach(match => {
77+
let cleanGuideline = match;
78+
cleanGuideline = cleanGuideline.replace(/<\/?>/g, "");
79+
cleanGuideline = cleanGuideline.replace(/<\/?code>/g, "`");
80+
cleanGuideline = cleanGuideline.replace(/^\s*"?(.+?)"?\s*,?\s*$/, "$1");
81+
cleanGuideline = cleanGuideline.trim();
82+
83+
if (cleanGuideline.length > 0 && !/^\s*$/.test(cleanGuideline)) {
84+
guidelines.push(`${guidelines.length + 1}. ${cleanGuideline}`);
85+
}
86+
});
87+
}
88+
89+
if (guidelines.length > 0) {
90+
return guidelines.join("\n");
91+
}
92+
}
93+
94+
let cleaned = content;
95+
cleaned = cleaned.replace(/<UsageGuidelines[\s\S]*?\/>/g, "");
96+
cleaned = cleaned.replace(/<\/?code>/g, "`");
97+
cleaned = cleaned.replace(/<\/?>/g, "");
98+
cleaned = cleaned.replace(/guidelines=\{[\s\S]*?\}/g, "");
99+
cleaned = cleaned.replace(/^\s*[[\]{}(),]\s*$/gm, "");
100+
cleaned = cleaned.replace(/\n\s*\n\s*\n/g, "\n\n");
101+
cleaned = cleaned.trim();
102+
103+
return cleaned || "No accessibility guidelines found in expected format.";
104+
}
105+
106+
export function run(): void {
107+
if (!fs.existsSync(componentsDir)) {
108+
console.error(`Components directory not found: ${componentsDir}`);
109+
process.exit(1);
110+
}
111+
112+
fs.mkdirSync(outputDir, { recursive: true });
113+
114+
const mdxFiles = getMdxFiles();
115+
console.log(`Extracting accessibility from ${mdxFiles.length} MDX files...`);
116+
mdxFiles.forEach(file => extractAccessibilityFromMdx(file));
117+
console.log("Accessibility extraction complete");
118+
}
119+
120+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
121+
if (isMain) {
122+
run();
123+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import parser from "@babel/parser";
4+
import _traverse from "@babel/traverse";
5+
import _generate from "@babel/generator";
6+
import { fileURLToPath } from "url";
7+
import type { File } from "@babel/types";
8+
9+
const traverse = (_traverse as unknown as { default: typeof _traverse }).default ?? _traverse;
10+
const generate = (_generate as unknown as { default: typeof _generate }).default ?? _generate;
11+
12+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
13+
const componentsDir = path.resolve(__dirname, "../../../docs/src/pages/components/");
14+
const outputDir = path.resolve(__dirname, "../../dist/metadata/examples/");
15+
16+
function getStoryFiles(): string[] {
17+
const storyFiles: string[] = [];
18+
19+
function traverseDirectory(dir: string): void {
20+
const entries = fs.readdirSync(dir, { withFileTypes: true });
21+
for (const entry of entries) {
22+
const fullPath = path.join(dir, entry.name);
23+
if (entry.isDirectory()) {
24+
traverseDirectory(fullPath);
25+
} else if (entry.isFile()) {
26+
if (entry.name.endsWith(".stories.tsx") || entry.name.endsWith(".stories.js")) {
27+
storyFiles.push(fullPath);
28+
}
29+
}
30+
}
31+
}
32+
33+
traverseDirectory(componentsDir);
34+
return storyFiles;
35+
}
36+
37+
export function generateCodeForOneLiner(ast: File, constName: string): string | null {
38+
let calleeCode: string | null = null;
39+
traverse(ast, {
40+
VariableDeclarator(nodePath) {
41+
if ((nodePath.node.id as { name?: string }).name === constName) {
42+
const init = nodePath.node.init as { callee?: { name?: string } } | null;
43+
if (init?.callee?.name !== "createComponentTemplate") {
44+
calleeCode = "const " + generate(nodePath.node).code;
45+
} else {
46+
calleeCode = "";
47+
}
48+
}
49+
}
50+
});
51+
return calleeCode;
52+
}
53+
54+
function extractMarkdown(file: string): void {
55+
const componentName = path.basename(file).split(".")[0];
56+
const outputFile = path.join(outputDir, componentName + ".md");
57+
const fileContent = fs.readFileSync(file, "utf-8");
58+
59+
const ast = parser.parse(fileContent, {
60+
sourceType: "module",
61+
plugins: ["typescript", "jsx"]
62+
});
63+
64+
let markdown = "# Storybook Code Examples\n\n";
65+
66+
traverse(ast, {
67+
ExportNamedDeclaration(nodePath) {
68+
if (nodePath.node.declaration && nodePath.node.declaration.type === "VariableDeclaration") {
69+
nodePath.node.declaration.declarations.forEach(declarator => {
70+
const storyName = (declarator.id as { name?: string }).name ?? "";
71+
if (declarator.init && declarator.init.type === "ObjectExpression") {
72+
let renderProp: { type: string; body?: unknown; callee?: { object?: { name?: string } } } | null = null;
73+
let nameProp: string | null = null;
74+
let codeBlock = "";
75+
76+
declarator.init.properties.forEach(prop => {
77+
if (prop.type !== "ObjectProperty") return;
78+
const key = prop.key as { name?: string };
79+
const value = prop.value as { type: string; value?: string };
80+
if (key.name === "render") {
81+
renderProp = value as typeof renderProp;
82+
}
83+
if (key.name === "name" && value.type === "StringLiteral") {
84+
nameProp = value.value ?? null;
85+
}
86+
});
87+
88+
if (renderProp) {
89+
const rp = renderProp as {
90+
type: string;
91+
body?: { type: string; body?: unknown[] };
92+
callee?: { object?: { name?: string } };
93+
};
94+
if (rp.type === "ArrowFunctionExpression") {
95+
const body = rp.body as { type: string; body?: unknown[] };
96+
if (body.type === "JSXFragment" || body.type === "MemberExpression" || body.type === "JSXElement") {
97+
codeBlock = generate(body as Parameters<typeof generate>[0]).code;
98+
} else if (body.type === "BlockStatement") {
99+
codeBlock = (body.body ?? [])
100+
.map(line => generate(line as Parameters<typeof generate>[0]).code)
101+
.join("\n");
102+
} else {
103+
codeBlock = generate(body as Parameters<typeof generate>[0]).code;
104+
}
105+
} else if (rp.type === "CallExpression") {
106+
const calleeName = rp.callee?.object?.name ?? "";
107+
codeBlock = generateCodeForOneLiner(ast, calleeName) ?? "";
108+
}
109+
}
110+
111+
const displayName = nameProp ?? storyName;
112+
if (codeBlock.length > 0) {
113+
markdown += `## ${displayName}\n\n\`\`\`tsx\n${codeBlock.trim()}\n\`\`\`\n\n`;
114+
}
115+
}
116+
});
117+
}
118+
}
119+
});
120+
121+
fs.writeFileSync(outputFile, markdown, "utf-8");
122+
}
123+
124+
export function run(): void {
125+
if (!fs.existsSync(componentsDir)) {
126+
console.error(`Components directory not found: ${componentsDir}`);
127+
process.exit(1);
128+
}
129+
130+
fs.mkdirSync(outputDir, { recursive: true });
131+
132+
const files = getStoryFiles();
133+
console.log(`Extracting examples from ${files.length} story files...`);
134+
files.forEach(file => extractMarkdown(file));
135+
console.log("Examples extraction complete");
136+
}
137+
138+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
139+
if (isMain) {
140+
run();
141+
}

packages/core/src/scripts/generate-metadata.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -566,10 +566,16 @@ async function main() {
566566
const finalJson = mergeResults(aggregatorRecords, docgenResults, storyMap);
567567
console.log(`Final output contains ${finalJson.length} component entries`);
568568

569-
const outPath = path.resolve(__dirname, "../../dist/metadata.json");
570-
fs.mkdirSync(path.dirname(outPath), { recursive: true });
571-
fs.writeFileSync(outPath, JSON.stringify(finalJson, null, 2), "utf-8");
572-
console.log(`Done! Wrote metadata to: ${outPath}`);
569+
const metadataDir = path.resolve(__dirname, "../../dist/metadata");
570+
fs.mkdirSync(metadataDir, { recursive: true });
571+
572+
const canonicalPath = path.join(metadataDir, "components.json");
573+
fs.writeFileSync(canonicalPath, JSON.stringify(finalJson, null, 2), "utf-8");
574+
console.log(`Wrote metadata to: ${canonicalPath}`);
575+
576+
const legacyPath = path.resolve(__dirname, "../../dist/metadata.json");
577+
fs.copyFileSync(canonicalPath, legacyPath);
578+
console.log(`Wrote backwards-compat copy to: ${legacyPath}`);
573579
} catch (error) {
574580
console.error("Failed to generate documentation:", error.message);
575581
process.exit(1);

packages/mcp/package.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,8 @@
2626
"test": "vitest --passWithNoTests",
2727
"start": "node dist/index.js",
2828
"clean": "rm -rf dist",
29-
"build": "tsc && yarn run generate-boilerplate",
30-
"debug": "npx @modelcontextprotocol/inspector node ./dist/index.js",
31-
"generate-boilerplate": "yarn run extract-code-examples && yarn run extract-accessibility",
32-
"extract-code-examples": "node scripts/extract-code-samples.js",
33-
"extract-accessibility": "node scripts/extract-accessibility.js"
29+
"build": "tsc",
30+
"debug": "npx @modelcontextprotocol/inspector node ./dist/index.js"
3431
},
3532
"dependencies": {
3633
"@modelcontextprotocol/sdk": "^1.11.4",

0 commit comments

Comments
 (0)