varmory 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -18
- package/dist/index.css +2 -2
- package/dist/varmory.mjs +14993 -10238
- package/dist/varmory.umd.js +532 -1483
- package/mcp/server.js +12 -10
- package/mcp/showcaseMcp.js +418 -112
- package/package.json +4 -4
- package/src/varmory/includes/normalizeQuasarApi.js +117 -0
- package/src/varmory/includes/package.json +1 -0
- package/dist/common-5lNzIzpb.mjs +0 -4
- package/dist/common-BKsX7fo_.mjs +0 -4
- package/dist/common-CF3aHLu9.mjs +0 -4
- package/dist/common-Choo61Du.mjs +0 -4
- package/dist/dark-BA_zVRY0.mjs +0 -4
- package/dist/dark-DOaFu-4U.mjs +0 -4
- package/dist/dark-DxprdSnN.mjs +0 -4
- package/dist/dark-KePaswAd.mjs +0 -4
- package/dist/light-3fU1ZoAr.mjs +0 -4
- package/dist/light-BjJD7fqj.mjs +0 -4
- package/dist/light-DtRlFPYS.mjs +0 -4
- package/dist/light-Ti6cE728.mjs +0 -4
package/mcp/showcaseMcp.js
CHANGED
|
@@ -1,16 +1,191 @@
|
|
|
1
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// varmory MCP — exposes showcase metadata, docs, and component APIs to AI
|
|
3
|
+
// agents over the Model Context Protocol.
|
|
4
|
+
//
|
|
5
|
+
// HIGH-LEVEL FLOW
|
|
6
|
+
// ───────────────
|
|
7
|
+
// 1. `attachShowcase(server, { rootDir, files })` is called once at boot.
|
|
8
|
+
// 2. We assemble a flat list of input file paths:
|
|
9
|
+
// • when `rootDir` is given, scan the varmory layout and collect the
|
|
10
|
+
// files we care about (README, docs/*.md, showcase categories,
|
|
11
|
+
// hand-written definitions, and Quasar's component src JSONs)
|
|
12
|
+
// • merge with any `files` the caller passed in explicitly
|
|
13
|
+
// 3. `loadFromFiles(paths)` routes each path by extension into three
|
|
14
|
+
// in-memory caches:
|
|
15
|
+
// • categories — .vue files grouped by their parent folder
|
|
16
|
+
// • docs — .md files (README.md + docs/*.md)
|
|
17
|
+
// • definitions — .json files (component API definitions)
|
|
18
|
+
// 4. Every definition is piped through `normalizeQuasarApi` to resolve
|
|
19
|
+
// `extends`, merge contributions from `mixins`, tag inherited entries, and
|
|
20
|
+
// sort (required → own → inherited). Definitions without mixins/extends
|
|
21
|
+
// pass through unchanged, so hand-written JSONs stay as-is.
|
|
22
|
+
// 5. MCP resources (listing endpoints) and tools (search / fetch endpoints)
|
|
23
|
+
// are registered on the server, reading from the caches.
|
|
24
|
+
//
|
|
25
|
+
// FILESYSTEM LAYOUT EXPECTED AT rootDir
|
|
26
|
+
// ─────────────────────────────────────
|
|
27
|
+
// README.md
|
|
28
|
+
// docs/*.md
|
|
29
|
+
// **/categories/<NN Name>/*.vue (anywhere under rootDir, recursive
|
|
30
|
+
// scan that skips node_modules / .git
|
|
31
|
+
// / dist / build)
|
|
32
|
+
// **/definitions/<Vendor>/*.json (same — any folder named
|
|
33
|
+
// `definitions` with vendor subfolders
|
|
34
|
+
// of JSON files)
|
|
35
|
+
// node_modules/quasar/src/… (optional; when
|
|
36
|
+
// present, Quasar
|
|
37
|
+
// components become
|
|
38
|
+
// queryable via
|
|
39
|
+
// get_api)
|
|
40
|
+
//
|
|
41
|
+
// Merge order matters: caller-supplied `files` are appended AFTER root-scanned
|
|
42
|
+
// files, so they override anything with the same definition/doc name.
|
|
43
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
1
45
|
import fs from 'fs';
|
|
2
46
|
import path from 'path';
|
|
3
47
|
import { z } from 'zod';
|
|
4
48
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
49
|
+
import normalizeQuasarApi from '../src/varmory/includes/normalizeQuasarApi.js';
|
|
50
|
+
|
|
51
|
+
// ── Filesystem helpers ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recursively list all files beneath `dir` whose filename passes `predicate`.
|
|
55
|
+
* Returns absolute paths. Safe if `dir` doesn't exist. `skip` is a set of
|
|
56
|
+
* directory names to skip (e.g. `node_modules`).
|
|
57
|
+
*/
|
|
58
|
+
function walkFiles(dir, predicate, skip = new Set()) {
|
|
59
|
+
const out = [];
|
|
60
|
+
if (!fs.existsSync(dir)) return out;
|
|
61
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
62
|
+
const full = path.join(dir, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
if (skip.has(entry.name)) continue;
|
|
65
|
+
out.push(...walkFiles(full, predicate, skip));
|
|
66
|
+
} else if (entry.isFile() && predicate(entry.name)) out.push(full);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Recursively locate all directories with a given name under `dir`, skipping
|
|
73
|
+
* the entries in `skip` and giving up after `maxDepth` levels. Returns
|
|
74
|
+
* absolute paths in sorted order.
|
|
75
|
+
*
|
|
76
|
+
* Depth 0 is `dir` itself. A `maxDepth` of 5 means we look at `dir` and 5
|
|
77
|
+
* layers below it — deep enough for monorepo layouts but shallow enough to
|
|
78
|
+
* not wander into surprise directories.
|
|
79
|
+
*/
|
|
80
|
+
function findDirsByName(dir, name, skip = new Set(), maxDepth = 5, depth = 0) {
|
|
81
|
+
const out = [];
|
|
82
|
+
if (!fs.existsSync(dir) || depth > maxDepth) return out;
|
|
83
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
84
|
+
if (!entry.isDirectory()) continue;
|
|
85
|
+
if (skip.has(entry.name)) continue;
|
|
86
|
+
const full = path.join(dir, entry.name);
|
|
87
|
+
if (entry.name === name) out.push(full);
|
|
88
|
+
out.push(...findDirsByName(full, name, skip, maxDepth, depth + 1));
|
|
89
|
+
}
|
|
90
|
+
return out.sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Collect all files we care about from a varmory root directory, returned as a
|
|
95
|
+
* flat array of absolute paths. The caller feeds this into `loadFromFiles`.
|
|
96
|
+
*
|
|
97
|
+
* Files are emitted in a deterministic order so the resulting categories
|
|
98
|
+
* and definitions lists are stable across runs:
|
|
99
|
+
* README.md
|
|
100
|
+
* docs/*.md (alphabetical)
|
|
101
|
+
* showcase/categories/<NN Name>/*.vue (folders by name, files a→z)
|
|
102
|
+
* showcase/definitions/<Vendor>/*.json (folders a→z, files a→z)
|
|
103
|
+
* node_modules/quasar/src/components (recursive) Q*.json (Quasar's own API,
|
|
104
|
+
* unless `quasar: false`)
|
|
105
|
+
*
|
|
106
|
+
* @param {string} rootDir
|
|
107
|
+
* @param {object} [opts]
|
|
108
|
+
* @param {boolean} [opts.quasar=true] - When false, skip Quasar's component JSONs.
|
|
109
|
+
* @param {number} [opts.maxDepth=5] - How deep to search for `categories/`
|
|
110
|
+
* and `definitions/` folders. Depth 0 is `rootDir` itself.
|
|
111
|
+
*/
|
|
112
|
+
function collectFilesFromRoot(rootDir, { quasar = true, maxDepth = 5 } = {}) {
|
|
113
|
+
const files = [];
|
|
114
|
+
|
|
115
|
+
// Skip these folder names when walking rootDir — they're expensive and
|
|
116
|
+
// never contain user-authored showcase/definition folders.
|
|
117
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build']);
|
|
118
|
+
|
|
119
|
+
// README.md at the root
|
|
120
|
+
const readme = path.join(rootDir, 'README.md');
|
|
121
|
+
if (fs.existsSync(readme)) files.push(readme);
|
|
122
|
+
|
|
123
|
+
// Markdown docs
|
|
124
|
+
const docsDir = path.join(rootDir, 'docs');
|
|
125
|
+
if (fs.existsSync(docsDir)) {
|
|
126
|
+
const mdFiles = fs.readdirSync(docsDir).filter(n => n.endsWith('.md')).sort();
|
|
127
|
+
for (const name of mdFiles) files.push(path.join(docsDir, name));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Showcase categories: any folder named `categories` under rootDir (not in
|
|
131
|
+
// node_modules etc.) contains `<NN Name>/*.vue` subfolders.
|
|
132
|
+
for (const catDir of findDirsByName(rootDir, 'categories', skip, maxDepth)) {
|
|
133
|
+
const folders = fs.readdirSync(catDir, { withFileTypes: true })
|
|
134
|
+
.filter(d => d.isDirectory())
|
|
135
|
+
.map(d => d.name)
|
|
136
|
+
.sort();
|
|
137
|
+
for (const folder of folders) {
|
|
138
|
+
const sub = path.join(catDir, folder);
|
|
139
|
+
const vueFiles = fs.readdirSync(sub).filter(n => n.endsWith('.vue')).sort();
|
|
140
|
+
for (const name of vueFiles) files.push(path.join(sub, name));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Hand-written API definitions: any folder named `definitions` under
|
|
145
|
+
// rootDir contains `<Vendor>/*.json` subfolders.
|
|
146
|
+
for (const defDir of findDirsByName(rootDir, 'definitions', skip, maxDepth)) {
|
|
147
|
+
const folders = fs.readdirSync(defDir, { withFileTypes: true })
|
|
148
|
+
.filter(d => d.isDirectory())
|
|
149
|
+
.map(d => d.name)
|
|
150
|
+
.sort();
|
|
151
|
+
for (const folder of folders) {
|
|
152
|
+
const sub = path.join(defDir, folder);
|
|
153
|
+
const jsonFiles = fs.readdirSync(sub).filter(n => n.endsWith('.json')).sort();
|
|
154
|
+
for (const name of jsonFiles) files.push(path.join(sub, name));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Quasar's authoritative component JSONs (contain mixins + extends).
|
|
159
|
+
// These are added AFTER hand-written defs so they override by key (QBtn,
|
|
160
|
+
// QInput, etc.). Skipped when `quasar: false`.
|
|
161
|
+
if (quasar) {
|
|
162
|
+
const quasarComp = path.join(rootDir, 'node_modules/quasar/src/components');
|
|
163
|
+
if (fs.existsSync(quasarComp)) {
|
|
164
|
+
for (const f of walkFiles(quasarComp, n => /^Q[A-Z].*\.json$/.test(n))) {
|
|
165
|
+
files.push(f);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return files;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Vue file parsing ────────────────────────────────────────────────────────
|
|
5
174
|
|
|
6
175
|
/**
|
|
7
|
-
* Extract the inner content of the root <template> block from a raw .vue
|
|
176
|
+
* Extract the inner content of the root <template> block from a raw .vue
|
|
177
|
+
* string. Handles nested <template> tags (used for slot templates) and strips
|
|
178
|
+
* the common leading indentation so the returned snippet reads cleanly when
|
|
179
|
+
* embedded in a markdown code fence.
|
|
8
180
|
*/
|
|
9
181
|
function extractTemplate(raw) {
|
|
10
182
|
if (!raw) return '';
|
|
11
183
|
const openTag = raw.indexOf('<template');
|
|
12
184
|
if (openTag === -1) return '';
|
|
13
185
|
const contentStart = raw.indexOf('>', openTag) + 1;
|
|
186
|
+
|
|
187
|
+
// Track nesting depth so a `<template #foo>` inside the root doesn't
|
|
188
|
+
// trick us into closing early on its matching `</template>`.
|
|
14
189
|
let depth = 1;
|
|
15
190
|
let i = contentStart;
|
|
16
191
|
while (i < raw.length && depth > 0) {
|
|
@@ -19,84 +194,134 @@ function extractTemplate(raw) {
|
|
|
19
194
|
if (nextClose === -1) break;
|
|
20
195
|
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
21
196
|
depth++;
|
|
22
|
-
i = nextOpen + 9;
|
|
197
|
+
i = nextOpen + 9; // past "<template"
|
|
23
198
|
} else {
|
|
24
199
|
depth--;
|
|
25
200
|
if (depth === 0) {
|
|
26
201
|
const content = raw.slice(contentStart, nextClose);
|
|
27
202
|
const lines = content.replace(/^\n|\n$/g, '').split('\n');
|
|
203
|
+
// Dedent by the minimum leading whitespace among non-blank lines.
|
|
28
204
|
const indent = Math.min(
|
|
29
205
|
...lines.filter(l => l.trim()).map(l => l.match(/^(\s*)/)[1].length),
|
|
30
206
|
);
|
|
31
207
|
return lines.map(l => l.slice(indent)).join('\n').trim();
|
|
32
208
|
}
|
|
33
|
-
i = nextClose + 11;
|
|
209
|
+
i = nextClose + 11; // past "</template>"
|
|
34
210
|
}
|
|
35
211
|
}
|
|
36
212
|
return '';
|
|
37
213
|
}
|
|
38
214
|
|
|
39
215
|
/**
|
|
40
|
-
*
|
|
216
|
+
* Locate the body of the `export default { ... }` block and return everything
|
|
217
|
+
* between its braces. Brace counter ignores braces inside strings, template
|
|
218
|
+
* literals, and `// ... ` / block comments so it survives realistic Vue files.
|
|
219
|
+
* Returns '' if no `export default` is found.
|
|
220
|
+
*/
|
|
221
|
+
function findExportDefaultBody(raw) {
|
|
222
|
+
const m = raw.match(/export\s+default\s*\{/);
|
|
223
|
+
if (!m) return '';
|
|
224
|
+
const open = m.index + m[0].length - 1; // index of the opening `{`
|
|
225
|
+
let depth = 1;
|
|
226
|
+
let i = open + 1;
|
|
227
|
+
while (i < raw.length && depth > 0) {
|
|
228
|
+
const ch = raw[i];
|
|
229
|
+
// Skip string / template literal — match quote and skip past it.
|
|
230
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
231
|
+
const q = ch;
|
|
232
|
+
i++;
|
|
233
|
+
while (i < raw.length) {
|
|
234
|
+
if (raw[i] === '\\') { i += 2; continue; }
|
|
235
|
+
if (raw[i] === q) { i++; break; }
|
|
236
|
+
i++;
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Skip line comment.
|
|
241
|
+
if (ch === '/' && raw[i + 1] === '/') {
|
|
242
|
+
while (i < raw.length && raw[i] !== '\n') i++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Skip block comment.
|
|
246
|
+
if (ch === '/' && raw[i + 1] === '*') {
|
|
247
|
+
i += 2;
|
|
248
|
+
while (i < raw.length - 1 && !(raw[i] === '*' && raw[i + 1] === '/')) i++;
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (ch === '{') depth++;
|
|
253
|
+
else if (ch === '}') {
|
|
254
|
+
depth--;
|
|
255
|
+
if (depth === 0) return raw.slice(open + 1, i);
|
|
256
|
+
}
|
|
257
|
+
i++;
|
|
258
|
+
}
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parse a single .vue showcase file into a flat metadata record. We don't run
|
|
264
|
+
* a full JS parser — we just look for the top-level `export default` options
|
|
265
|
+
* we care about (`label`, `icon`, `importName`, `importFrom`).
|
|
266
|
+
*
|
|
267
|
+
* Match constraint: the option must appear at the export default block's first
|
|
268
|
+
* indent level. This avoids picking up string values nested deeper (e.g. a
|
|
269
|
+
* `label: 'false'` inside a `data() { ... accentOptions: [{ label: 'false' }] }`
|
|
270
|
+
* would otherwise match before the real top-level `label`).
|
|
41
271
|
*/
|
|
42
272
|
function parseVueFile(filePath) {
|
|
43
273
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
44
274
|
const name = path.basename(filePath, '.vue');
|
|
45
275
|
const template = extractTemplate(raw);
|
|
46
276
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
277
|
+
const body = findExportDefaultBody(raw);
|
|
278
|
+
|
|
279
|
+
// Detect the indent unit used by the body (tab or N spaces) by reading
|
|
280
|
+
// the first non-blank indented line.
|
|
281
|
+
const indentMatch = /^([\t ]+)\S/m.exec(body);
|
|
282
|
+
const indent = indentMatch ? indentMatch[1] : ' ';
|
|
283
|
+
// Anchor regexes to "newline + exactly this indent + key:" so deeper
|
|
284
|
+
// nested keys don't match.
|
|
285
|
+
const at = (key, valuePattern) =>
|
|
286
|
+
new RegExp(`(?:^|\\n)${indent.replace(/ /g, ' ').replace(/\t/g, '\\t')}${key}\\s*:\\s*${valuePattern}`);
|
|
287
|
+
|
|
288
|
+
const labelMatch = at('label', `(['"])(.+?)\\1`).exec(body);
|
|
289
|
+
const iconMatch = at('icon', `(['"])(.+?)\\1`).exec(body);
|
|
290
|
+
const importFromMatch = at('importFrom', `(['"])(.+?)\\1`).exec(body);
|
|
291
|
+
|
|
292
|
+
// `importName` can be either a string or an array of strings:
|
|
293
|
+
// importName: 'QBtn'
|
|
294
|
+
// importName: ['QSlider', 'QRange']
|
|
295
|
+
const importArrayMatch = at('importName', `\\[([^\\]]+)\\]`).exec(body);
|
|
53
296
|
let importName = null;
|
|
54
297
|
if (importArrayMatch) {
|
|
55
298
|
importName = importArrayMatch[1].match(/['"]([^'"]+)['"]/g)?.map(s => s.replace(/['"]/g, '')) || null;
|
|
56
299
|
} else {
|
|
57
|
-
const importNameMatch =
|
|
58
|
-
importName = importNameMatch?.[
|
|
300
|
+
const importNameMatch = at('importName', `(['"])(.+?)\\1`).exec(body);
|
|
301
|
+
importName = importNameMatch?.[2] || null;
|
|
59
302
|
}
|
|
60
303
|
|
|
61
304
|
return {
|
|
62
305
|
name,
|
|
63
|
-
label: labelMatch?.[
|
|
64
|
-
icon: iconMatch?.[
|
|
306
|
+
label: labelMatch?.[2] || name,
|
|
307
|
+
icon: iconMatch?.[2] || null,
|
|
65
308
|
importName,
|
|
66
|
-
importFrom: importFromMatch?.[
|
|
309
|
+
importFrom: importFromMatch?.[2] || null,
|
|
67
310
|
template,
|
|
68
311
|
};
|
|
69
312
|
}
|
|
70
313
|
|
|
71
|
-
|
|
72
|
-
* Scan showcase/categories/ directory and return structured data.
|
|
73
|
-
*/
|
|
74
|
-
function loadCategoriesFromDir(catDir) {
|
|
75
|
-
if (!fs.existsSync(catDir)) return {};
|
|
76
|
-
|
|
77
|
-
const categories = {};
|
|
78
|
-
const folders = fs.readdirSync(catDir, { withFileTypes: true })
|
|
79
|
-
.filter(d => d.isDirectory())
|
|
80
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
81
|
-
|
|
82
|
-
for (const folder of folders) {
|
|
83
|
-
const displayName = folder.name.replace(/^\d+\s*/, '');
|
|
84
|
-
const files = fs.readdirSync(path.join(catDir, folder.name))
|
|
85
|
-
.filter(f => f.endsWith('.vue'))
|
|
86
|
-
.sort();
|
|
87
|
-
|
|
88
|
-
categories[displayName] = files.map(file =>
|
|
89
|
-
parseVueFile(path.join(catDir, folder.name, file)),
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return categories;
|
|
93
|
-
}
|
|
314
|
+
// ── Unified loader ──────────────────────────────────────────────────────────
|
|
94
315
|
|
|
95
316
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* .
|
|
317
|
+
* Route a flat list of file paths by extension into the three in-memory caches
|
|
318
|
+
* used by MCP tools:
|
|
319
|
+
* .vue → categories[<parent-folder-with-NN-stripped>].push(parsedItem)
|
|
320
|
+
* .md → docs[<name>] (README.md keeps its `README` key)
|
|
321
|
+
* .json → definitions[<name>] (by filename, without extension)
|
|
322
|
+
*
|
|
323
|
+
* Later paths overwrite earlier ones for docs/definitions by the same name,
|
|
324
|
+
* so callers can override root-scanned entries via `options.files`.
|
|
100
325
|
*/
|
|
101
326
|
function loadFromFiles(filePaths) {
|
|
102
327
|
const categories = {};
|
|
@@ -109,111 +334,149 @@ function loadFromFiles(filePaths) {
|
|
|
109
334
|
const ext = path.extname(abs);
|
|
110
335
|
|
|
111
336
|
if (ext === '.vue') {
|
|
337
|
+
// Group by the .vue's parent directory name, stripping any
|
|
338
|
+
// numeric prefix (e.g. "04 Buttons" → "Buttons") — that's the
|
|
339
|
+
// category naming convention used across the showcase folder.
|
|
112
340
|
const folder = path.basename(path.dirname(abs));
|
|
113
341
|
const cat = folder.replace(/^\d+\s*/, '');
|
|
114
342
|
if (!categories[cat]) categories[cat] = [];
|
|
115
343
|
categories[cat].push(parseVueFile(abs));
|
|
116
344
|
} else if (ext === '.md') {
|
|
117
345
|
const name = path.basename(abs, '.md');
|
|
118
|
-
|
|
119
|
-
docs[key] = fs.readFileSync(abs, 'utf-8');
|
|
346
|
+
docs[name] = fs.readFileSync(abs, 'utf-8');
|
|
120
347
|
} else if (ext === '.json') {
|
|
121
348
|
const name = path.basename(abs, '.json');
|
|
122
|
-
definitions[name] = JSON.parse(fs.readFileSync(abs, 'utf-8'));
|
|
349
|
+
try { definitions[name] = JSON.parse(fs.readFileSync(abs, 'utf-8')); }
|
|
350
|
+
catch { /* ignore malformed json; don't poison the cache */ }
|
|
123
351
|
}
|
|
124
352
|
}
|
|
125
353
|
|
|
126
354
|
return { categories, docs, definitions };
|
|
127
355
|
}
|
|
128
356
|
|
|
357
|
+
// ── Quasar normalizer data ─────────────────────────────────────────────────
|
|
358
|
+
|
|
129
359
|
/**
|
|
130
|
-
* Load
|
|
360
|
+
* Load the two kinds of "resolver data" that `normalizeQuasarApi` needs to
|
|
361
|
+
* flatten a raw Quasar src JSON:
|
|
362
|
+
*
|
|
363
|
+
* apiExtends — the shared prop/slot/event defs referenced by
|
|
364
|
+
* `"extends": "<name>"` in component JSONs. Lives in
|
|
365
|
+
* `quasar/src/api.extends.json`.
|
|
366
|
+
* mixinLookup — every non-Q*.json file under quasar/src/ keyed by its
|
|
367
|
+
* relative path minus the `.json` extension. Matches the
|
|
368
|
+
* mixin reference format used in Quasar JSONs, e.g.
|
|
369
|
+
* `"composables/private.use-size/use-size"` or
|
|
370
|
+
* `"components/btn/use-btn"`.
|
|
371
|
+
*
|
|
372
|
+
* Returns empty lookups when Quasar isn't installed at rootDir — definitions
|
|
373
|
+
* without mixins/extends still pass through normalize unchanged.
|
|
131
374
|
*/
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
docs['README'] = fs.readFileSync(readme, 'utf-8');
|
|
375
|
+
function loadQuasarNormalizerData(rootDir) {
|
|
376
|
+
const quasarSrc = rootDir ? path.join(rootDir, 'node_modules/quasar/src') : null;
|
|
377
|
+
if (!quasarSrc || !fs.existsSync(quasarSrc)) {
|
|
378
|
+
return { apiExtends: {}, mixinLookup: {} };
|
|
137
379
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
380
|
+
|
|
381
|
+
const readJson = (p) => {
|
|
382
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); }
|
|
383
|
+
catch { return null; }
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const apiExtends = readJson(path.join(quasarSrc, 'api.extends.json')) || {};
|
|
387
|
+
|
|
388
|
+
const mixinLookup = {};
|
|
389
|
+
for (const file of walkFiles(quasarSrc, n => n.endsWith('.json'))) {
|
|
390
|
+
const base = path.basename(file);
|
|
391
|
+
// Skip the extends source and any component JSON — we only want
|
|
392
|
+
// auxiliary mixin/composable definitions here.
|
|
393
|
+
if (base === 'api.extends.json' || /^Q[A-Z]/.test(base)) continue;
|
|
394
|
+
const rel = path.relative(quasarSrc, file).replace(/\\/g, '/').replace(/\.json$/, '');
|
|
395
|
+
const json = readJson(file);
|
|
396
|
+
if (json) mixinLookup[rel] = json;
|
|
143
397
|
}
|
|
144
|
-
|
|
398
|
+
|
|
399
|
+
return { apiExtends, mixinLookup };
|
|
145
400
|
}
|
|
146
401
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const defsDir = path.join(rootDir, 'src/varmory/showcase/definitions');
|
|
153
|
-
if (!fs.existsSync(defsDir)) return defs;
|
|
154
|
-
|
|
155
|
-
const folders = fs.readdirSync(defsDir, { withFileTypes: true })
|
|
156
|
-
.filter(d => d.isDirectory());
|
|
157
|
-
|
|
158
|
-
for (const folder of folders) {
|
|
159
|
-
const files = fs.readdirSync(path.join(defsDir, folder.name))
|
|
160
|
-
.filter(f => f.endsWith('.json'));
|
|
161
|
-
for (const file of files) {
|
|
162
|
-
const name = file.replace('.json', '');
|
|
163
|
-
const content = fs.readFileSync(path.join(defsDir, folder.name, file), 'utf-8');
|
|
164
|
-
defs[name] = JSON.parse(content);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return defs;
|
|
402
|
+
// ── Markdown formatters (used by the get_api tool) ─────────────────────────
|
|
403
|
+
|
|
404
|
+
function formatType(type) {
|
|
405
|
+
if (!type) return 'any';
|
|
406
|
+
return Array.isArray(type) ? type.join(' | ') : type;
|
|
168
407
|
}
|
|
169
408
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
|
|
409
|
+
/** Small trailing tag rendered next to inherited props/slots/events/methods. */
|
|
410
|
+
function formatMixinTag(entry) {
|
|
411
|
+
return entry?._mixin ? ` _[inherited: ${entry._mixin}]_` : '';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Render a component's props as a markdown bullet list. */
|
|
173
415
|
function formatProps(apiDef) {
|
|
174
416
|
if (!apiDef?.props) return 'No props defined.';
|
|
175
417
|
const lines = [];
|
|
176
418
|
for (const [name, prop] of Object.entries(apiDef.props)) {
|
|
419
|
+
const required = prop.required ? ' **(required)**' : '';
|
|
177
420
|
const def = prop.default !== undefined ? ` (default: ${prop.default})` : '';
|
|
178
|
-
|
|
421
|
+
const values = Array.isArray(prop.values) && prop.values.length
|
|
422
|
+
? ` [values: ${prop.values.join(', ')}]`
|
|
423
|
+
: '';
|
|
424
|
+
lines.push(`- **${name}**${required}: ${formatType(prop.type)}${def}${values} — ${prop.desc || ''}${formatMixinTag(prop)}`);
|
|
179
425
|
}
|
|
180
426
|
return lines.join('\n');
|
|
181
427
|
}
|
|
182
428
|
|
|
429
|
+
// ── Public entry point ─────────────────────────────────────────────────────
|
|
430
|
+
|
|
183
431
|
/**
|
|
184
432
|
* Attach showcase resources and tools to an MCP server instance.
|
|
185
433
|
*
|
|
186
434
|
* @param {McpServer} server - An @modelcontextprotocol/sdk McpServer instance
|
|
187
435
|
* @param {object} [options]
|
|
188
|
-
* @param {string} [options.rootDir] - Absolute path to
|
|
189
|
-
*
|
|
190
|
-
*
|
|
436
|
+
* @param {string} [options.rootDir] - Absolute path to a varmory root. When
|
|
437
|
+
* provided, the standard showcase layout is scanned automatically. When
|
|
438
|
+
* omitted, the filesystem is NOT scanned — only `options.files` are
|
|
439
|
+
* loaded.
|
|
440
|
+
* @param {string[]} [options.files] - Additional file paths to include on top
|
|
441
|
+
* of whatever `rootDir` produced. Classified by extension — see
|
|
442
|
+
* `loadFromFiles`. Useful for injecting fixtures in tests or overriding
|
|
443
|
+
* a specific definition/doc.
|
|
444
|
+
* @param {boolean} [options.quasar=true] - When `false`, Quasar's component
|
|
445
|
+
* JSONs and mixin/extends data are NOT auto-loaded from `node_modules`.
|
|
446
|
+
* Definitions provided via `files` still pass through the normalizer
|
|
447
|
+
* but won't have access to Quasar's shared `extends` / mixin pool.
|
|
448
|
+
* @param {number} [options.maxDepth=5] - How deep the `categories/` /
|
|
449
|
+
* `definitions/` folder search goes under `rootDir`.
|
|
450
|
+
* @returns {McpServer} The same server instance, now with resources and tools
|
|
451
|
+
* attached.
|
|
191
452
|
*/
|
|
192
453
|
export default function attachShowcase(server, options = {}) {
|
|
193
|
-
const rootDir = options.rootDir ||
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
454
|
+
const rootDir = options.rootDir || null;
|
|
455
|
+
const quasar = options.quasar !== false; // default true
|
|
456
|
+
const maxDepth = options.maxDepth ?? 5;
|
|
457
|
+
|
|
458
|
+
// Build one flat file list from (1) root-scan and (2) caller overrides,
|
|
459
|
+
// then run both through the same loader. `options.files` wins on
|
|
460
|
+
// same-named docs/definitions because it comes last.
|
|
461
|
+
const allFiles = [
|
|
462
|
+
...(rootDir ? collectFilesFromRoot(rootDir, { quasar, maxDepth }) : []),
|
|
463
|
+
...(options.files || []),
|
|
464
|
+
];
|
|
465
|
+
const { categories, docs, definitions } = loadFromFiles(allFiles);
|
|
466
|
+
|
|
467
|
+
// Resolve `extends` / `mixins` in every definition. Pure no-op for
|
|
468
|
+
// definitions that don't have those markers, so hand-written JSONs
|
|
469
|
+
// (e.g. JPanel) are preserved byte-for-byte. When `quasar: false`, skip
|
|
470
|
+
// the (heavy) walk of node_modules/quasar/src — definitions still pass
|
|
471
|
+
// through normalize, just without Quasar's shared resolver pool.
|
|
472
|
+
const normalizerData = quasar ? loadQuasarNormalizerData(rootDir) : { apiExtends: {}, mixinLookup: {} };
|
|
473
|
+
for (const key of Object.keys(definitions)) {
|
|
474
|
+
definitions[key] = normalizeQuasarApi(definitions[key], normalizerData);
|
|
204
475
|
}
|
|
205
476
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!categories[cat]) categories[cat] = [];
|
|
210
|
-
categories[cat].push(...items);
|
|
211
|
-
}
|
|
212
|
-
Object.assign(docs, fromFiles.docs);
|
|
213
|
-
Object.assign(definitions, fromFiles.definitions);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ── Resources ──────────────────────────────────────────
|
|
477
|
+
// ── Resources ──────────────────────────────────────────────────────
|
|
478
|
+
// Resources are URI-addressable listings; MCP clients typically enumerate
|
|
479
|
+
// them to discover what's available.
|
|
217
480
|
|
|
218
481
|
server.resource('docs-list', 'showcase://docs', async () => {
|
|
219
482
|
const names = Object.keys(docs);
|
|
@@ -259,10 +522,14 @@ export default function attachShowcase(server, options = {}) {
|
|
|
259
522
|
};
|
|
260
523
|
});
|
|
261
524
|
|
|
262
|
-
// ── Tools
|
|
525
|
+
// ── Tools ──────────────────────────────────────────────────────────
|
|
526
|
+
// Tools are functions the model can call. All of ours are read-only —
|
|
527
|
+
// they only query the caches built above.
|
|
263
528
|
|
|
264
529
|
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
265
530
|
|
|
531
|
+
// Search across the showcase category tree. Matches on the raw filename,
|
|
532
|
+
// the display label, or any `importName` the Vue file declares.
|
|
266
533
|
server.tool(
|
|
267
534
|
'search_components',
|
|
268
535
|
'Search showcase components by name or label',
|
|
@@ -290,6 +557,8 @@ export default function attachShowcase(server, options = {}) {
|
|
|
290
557
|
},
|
|
291
558
|
);
|
|
292
559
|
|
|
560
|
+
// Fetch a single showcase component's metadata + template snippet. Tries
|
|
561
|
+
// a case-insensitive match against file name, label, and importName.
|
|
293
562
|
server.tool(
|
|
294
563
|
'get_component',
|
|
295
564
|
'Get a showcase component\'s template code and metadata',
|
|
@@ -312,6 +581,9 @@ export default function attachShowcase(server, options = {}) {
|
|
|
312
581
|
},
|
|
313
582
|
);
|
|
314
583
|
|
|
584
|
+
// Render a component's full API (props, slots, events, methods) as
|
|
585
|
+
// markdown. Works on both hand-written and Quasar-src definitions — the
|
|
586
|
+
// normalize step upstream makes them interchangeable here.
|
|
315
587
|
server.tool(
|
|
316
588
|
'get_api',
|
|
317
589
|
'Get the API definition (props, slots, events) for a component',
|
|
@@ -323,22 +595,55 @@ export default function attachShowcase(server, options = {}) {
|
|
|
323
595
|
return { content: [{ type: 'text', text: `No API definition found for "${name}". Available: ${Object.keys(definitions).join(', ')}` }] };
|
|
324
596
|
}
|
|
325
597
|
const lines = [`# ${name} API`, '', '## Props', formatProps(def)];
|
|
326
|
-
|
|
598
|
+
|
|
599
|
+
if (def.slots && Object.keys(def.slots).length) {
|
|
327
600
|
lines.push('', '## Slots');
|
|
328
601
|
for (const [slot, info] of Object.entries(def.slots)) {
|
|
329
|
-
lines.push(`- **${slot}** — ${info.desc || ''}`);
|
|
602
|
+
lines.push(`- **${slot}** — ${info.desc || ''}${formatMixinTag(info)}`);
|
|
603
|
+
// Scoped slot props — indented under the slot bullet.
|
|
604
|
+
if (info.scope && Object.keys(info.scope).length) {
|
|
605
|
+
for (const [k, v] of Object.entries(info.scope)) {
|
|
606
|
+
lines.push(` - \`props.${k}\`: ${formatType(v?.type)} — ${v?.desc || ''}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
330
609
|
}
|
|
331
610
|
}
|
|
332
|
-
|
|
611
|
+
|
|
612
|
+
if (def.events && Object.keys(def.events).length) {
|
|
333
613
|
lines.push('', '## Events');
|
|
334
614
|
for (const [event, info] of Object.entries(def.events)) {
|
|
335
|
-
lines.push(`- **${event}** — ${info.desc || ''}`);
|
|
615
|
+
lines.push(`- **${event}** — ${info.desc || ''}${formatMixinTag(info)}`);
|
|
616
|
+
// Event payload args — one bullet per param.
|
|
617
|
+
if (info.params && Object.keys(info.params).length) {
|
|
618
|
+
for (const [k, v] of Object.entries(info.params)) {
|
|
619
|
+
lines.push(` - \`${k}\`: ${formatType(v?.type)} — ${v?.desc || ''}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (def.methods && Object.keys(def.methods).length) {
|
|
626
|
+
lines.push('', '## Methods');
|
|
627
|
+
for (const [method, info] of Object.entries(def.methods)) {
|
|
628
|
+
lines.push(`- **${method}()** — ${info.desc || ''}${formatMixinTag(info)}`);
|
|
629
|
+
// Method params + return — indented under the method bullet.
|
|
630
|
+
if (info.params && Object.keys(info.params).length) {
|
|
631
|
+
for (const [k, v] of Object.entries(info.params)) {
|
|
632
|
+
const req = v?.required ? ' *(required)*' : '';
|
|
633
|
+
lines.push(` - param \`${k}\`${req}: ${formatType(v?.type)} — ${v?.desc || ''}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (info.returns && typeof info.returns === 'object') {
|
|
637
|
+
lines.push(` - returns: ${formatType(info.returns.type)} — ${info.returns.desc || ''}`);
|
|
638
|
+
}
|
|
336
639
|
}
|
|
337
640
|
}
|
|
338
641
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
339
642
|
},
|
|
340
643
|
);
|
|
341
644
|
|
|
645
|
+
// Simple full-text search across docs. Returns the first matching line as
|
|
646
|
+
// a short snippet so the model can decide whether to fetch the full doc.
|
|
342
647
|
server.tool(
|
|
343
648
|
'search_docs',
|
|
344
649
|
'Search documentation pages by name or content',
|
|
@@ -364,6 +669,7 @@ export default function attachShowcase(server, options = {}) {
|
|
|
364
669
|
},
|
|
365
670
|
);
|
|
366
671
|
|
|
672
|
+
// Return the full raw markdown of a single doc.
|
|
367
673
|
server.tool(
|
|
368
674
|
'get_doc',
|
|
369
675
|
'Read a documentation page by name',
|