varmory 1.0.3 → 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 +40 -27
- package/dist/index.css +2 -2
- package/dist/varmory.mjs +18759 -12481
- package/dist/varmory.umd.js +784 -1309
- package/mcp/server.js +12 -10
- package/mcp/showcaseMcp.js +432 -113
- package/package.json +4 -6
- package/src/varmory/includes/normalizeQuasarApi.js +117 -0
- package/src/varmory/includes/package.json +1 -0
- package/dist/common-BpSYL5sB.mjs +0 -4
- package/dist/common-CHgxSk_P.mjs +0 -4
- package/dist/common-CsPPY3RM.mjs +0 -4
- package/dist/common-as5RLce2.mjs +0 -4
- package/dist/dark-BVjDuXwf.mjs +0 -4
- package/dist/dark-BgJR45a-.mjs +0 -4
- package/dist/dark-DUdEp-u-.mjs +0 -4
- package/dist/dark-bS8ONQp2.mjs +0 -4
- package/dist/light-BZMuS_Q9.mjs +0 -4
- package/dist/light-BiNlctIT.mjs +0 -4
- package/dist/light-C-AEl-Qw.mjs +0 -4
- package/dist/light-Dx5f6KH1.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,75 +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
|
-
|
|
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);
|
|
296
|
+
let importName = null;
|
|
297
|
+
if (importArrayMatch) {
|
|
298
|
+
importName = importArrayMatch[1].match(/['"]([^'"]+)['"]/g)?.map(s => s.replace(/['"]/g, '')) || null;
|
|
299
|
+
} else {
|
|
300
|
+
const importNameMatch = at('importName', `(['"])(.+?)\\1`).exec(body);
|
|
301
|
+
importName = importNameMatch?.[2] || null;
|
|
302
|
+
}
|
|
51
303
|
|
|
52
304
|
return {
|
|
53
305
|
name,
|
|
54
|
-
label: labelMatch?.[
|
|
55
|
-
icon: iconMatch?.[
|
|
56
|
-
importName
|
|
57
|
-
importFrom: importFromMatch?.[
|
|
306
|
+
label: labelMatch?.[2] || name,
|
|
307
|
+
icon: iconMatch?.[2] || null,
|
|
308
|
+
importName,
|
|
309
|
+
importFrom: importFromMatch?.[2] || null,
|
|
58
310
|
template,
|
|
59
311
|
};
|
|
60
312
|
}
|
|
61
313
|
|
|
62
|
-
|
|
63
|
-
* Scan showcase/categories/ directory and return structured data.
|
|
64
|
-
*/
|
|
65
|
-
function loadCategoriesFromDir(catDir) {
|
|
66
|
-
if (!fs.existsSync(catDir)) return {};
|
|
67
|
-
|
|
68
|
-
const categories = {};
|
|
69
|
-
const folders = fs.readdirSync(catDir, { withFileTypes: true })
|
|
70
|
-
.filter(d => d.isDirectory())
|
|
71
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
72
|
-
|
|
73
|
-
for (const folder of folders) {
|
|
74
|
-
const displayName = folder.name.replace(/^\d+\s*/, '');
|
|
75
|
-
const files = fs.readdirSync(path.join(catDir, folder.name))
|
|
76
|
-
.filter(f => f.endsWith('.vue'))
|
|
77
|
-
.sort();
|
|
78
|
-
|
|
79
|
-
categories[displayName] = files.map(file =>
|
|
80
|
-
parseVueFile(path.join(catDir, folder.name, file)),
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
return categories;
|
|
84
|
-
}
|
|
314
|
+
// ── Unified loader ──────────────────────────────────────────────────────────
|
|
85
315
|
|
|
86
316
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* .
|
|
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`.
|
|
91
325
|
*/
|
|
92
326
|
function loadFromFiles(filePaths) {
|
|
93
327
|
const categories = {};
|
|
@@ -100,111 +334,149 @@ function loadFromFiles(filePaths) {
|
|
|
100
334
|
const ext = path.extname(abs);
|
|
101
335
|
|
|
102
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.
|
|
103
340
|
const folder = path.basename(path.dirname(abs));
|
|
104
341
|
const cat = folder.replace(/^\d+\s*/, '');
|
|
105
342
|
if (!categories[cat]) categories[cat] = [];
|
|
106
343
|
categories[cat].push(parseVueFile(abs));
|
|
107
344
|
} else if (ext === '.md') {
|
|
108
345
|
const name = path.basename(abs, '.md');
|
|
109
|
-
|
|
110
|
-
docs[key] = fs.readFileSync(abs, 'utf-8');
|
|
346
|
+
docs[name] = fs.readFileSync(abs, 'utf-8');
|
|
111
347
|
} else if (ext === '.json') {
|
|
112
348
|
const name = path.basename(abs, '.json');
|
|
113
|
-
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 */ }
|
|
114
351
|
}
|
|
115
352
|
}
|
|
116
353
|
|
|
117
354
|
return { categories, docs, definitions };
|
|
118
355
|
}
|
|
119
356
|
|
|
357
|
+
// ── Quasar normalizer data ─────────────────────────────────────────────────
|
|
358
|
+
|
|
120
359
|
/**
|
|
121
|
-
* 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.
|
|
122
374
|
*/
|
|
123
|
-
function
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
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: {} };
|
|
128
379
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
|
134
397
|
}
|
|
135
|
-
|
|
398
|
+
|
|
399
|
+
return { apiExtends, mixinLookup };
|
|
136
400
|
}
|
|
137
401
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const defsDir = path.join(rootDir, 'src/varmory/showcase/definitions');
|
|
144
|
-
if (!fs.existsSync(defsDir)) return defs;
|
|
145
|
-
|
|
146
|
-
const folders = fs.readdirSync(defsDir, { withFileTypes: true })
|
|
147
|
-
.filter(d => d.isDirectory());
|
|
148
|
-
|
|
149
|
-
for (const folder of folders) {
|
|
150
|
-
const files = fs.readdirSync(path.join(defsDir, folder.name))
|
|
151
|
-
.filter(f => f.endsWith('.json'));
|
|
152
|
-
for (const file of files) {
|
|
153
|
-
const name = file.replace('.json', '');
|
|
154
|
-
const content = fs.readFileSync(path.join(defsDir, folder.name, file), 'utf-8');
|
|
155
|
-
defs[name] = JSON.parse(content);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
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;
|
|
159
407
|
}
|
|
160
408
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
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. */
|
|
164
415
|
function formatProps(apiDef) {
|
|
165
416
|
if (!apiDef?.props) return 'No props defined.';
|
|
166
417
|
const lines = [];
|
|
167
418
|
for (const [name, prop] of Object.entries(apiDef.props)) {
|
|
419
|
+
const required = prop.required ? ' **(required)**' : '';
|
|
168
420
|
const def = prop.default !== undefined ? ` (default: ${prop.default})` : '';
|
|
169
|
-
|
|
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)}`);
|
|
170
425
|
}
|
|
171
426
|
return lines.join('\n');
|
|
172
427
|
}
|
|
173
428
|
|
|
429
|
+
// ── Public entry point ─────────────────────────────────────────────────────
|
|
430
|
+
|
|
174
431
|
/**
|
|
175
432
|
* Attach showcase resources and tools to an MCP server instance.
|
|
176
433
|
*
|
|
177
434
|
* @param {McpServer} server - An @modelcontextprotocol/sdk McpServer instance
|
|
178
435
|
* @param {object} [options]
|
|
179
|
-
* @param {string} [options.rootDir] - Absolute path to
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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.
|
|
182
452
|
*/
|
|
183
453
|
export default function attachShowcase(server, options = {}) {
|
|
184
|
-
const rootDir = options.rootDir ||
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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);
|
|
195
475
|
}
|
|
196
476
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!categories[cat]) categories[cat] = [];
|
|
201
|
-
categories[cat].push(...items);
|
|
202
|
-
}
|
|
203
|
-
Object.assign(docs, fromFiles.docs);
|
|
204
|
-
Object.assign(definitions, fromFiles.definitions);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ── Resources ──────────────────────────────────────────
|
|
477
|
+
// ── Resources ──────────────────────────────────────────────────────
|
|
478
|
+
// Resources are URI-addressable listings; MCP clients typically enumerate
|
|
479
|
+
// them to discover what's available.
|
|
208
480
|
|
|
209
481
|
server.resource('docs-list', 'showcase://docs', async () => {
|
|
210
482
|
const names = Object.keys(docs);
|
|
@@ -231,7 +503,8 @@ export default function attachShowcase(server, options = {}) {
|
|
|
231
503
|
for (const [cat, items] of Object.entries(categories)) {
|
|
232
504
|
lines.push(`## ${cat}`);
|
|
233
505
|
for (const item of items) {
|
|
234
|
-
const
|
|
506
|
+
const impNames = Array.isArray(item.importName) ? item.importName.join(', ') : item.importName;
|
|
507
|
+
const imp = impNames ? ` (${impNames})` : '';
|
|
235
508
|
lines.push(`- ${item.label}${imp}`);
|
|
236
509
|
}
|
|
237
510
|
lines.push('');
|
|
@@ -249,10 +522,14 @@ export default function attachShowcase(server, options = {}) {
|
|
|
249
522
|
};
|
|
250
523
|
});
|
|
251
524
|
|
|
252
|
-
// ── 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.
|
|
253
528
|
|
|
254
529
|
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
255
530
|
|
|
531
|
+
// Search across the showcase category tree. Matches on the raw filename,
|
|
532
|
+
// the display label, or any `importName` the Vue file declares.
|
|
256
533
|
server.tool(
|
|
257
534
|
'search_components',
|
|
258
535
|
'Search showcase components by name or label',
|
|
@@ -263,7 +540,8 @@ export default function attachShowcase(server, options = {}) {
|
|
|
263
540
|
const results = [];
|
|
264
541
|
for (const [cat, items] of Object.entries(categories)) {
|
|
265
542
|
for (const item of items) {
|
|
266
|
-
|
|
543
|
+
const impNames = Array.isArray(item.importName) ? item.importName : (item.importName ? [item.importName] : []);
|
|
544
|
+
if (item.name.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || impNames.some(n => n.toLowerCase().includes(q))) {
|
|
267
545
|
results.push({ category: cat, name: item.name, label: item.label });
|
|
268
546
|
}
|
|
269
547
|
}
|
|
@@ -279,6 +557,8 @@ export default function attachShowcase(server, options = {}) {
|
|
|
279
557
|
},
|
|
280
558
|
);
|
|
281
559
|
|
|
560
|
+
// Fetch a single showcase component's metadata + template snippet. Tries
|
|
561
|
+
// a case-insensitive match against file name, label, and importName.
|
|
282
562
|
server.tool(
|
|
283
563
|
'get_component',
|
|
284
564
|
'Get a showcase component\'s template code and metadata',
|
|
@@ -287,10 +567,12 @@ export default function attachShowcase(server, options = {}) {
|
|
|
287
567
|
async ({ name }) => {
|
|
288
568
|
const q = name.toLowerCase();
|
|
289
569
|
for (const [cat, items] of Object.entries(categories)) {
|
|
290
|
-
const
|
|
570
|
+
const impMatch = (imp) => { const names = Array.isArray(imp) ? imp : (imp ? [imp] : []); return names.some(n => n.toLowerCase() === q); };
|
|
571
|
+
const item = items.find(i => i.name.toLowerCase() === q || i.label.toLowerCase() === q || impMatch(i.importName));
|
|
291
572
|
if (item) {
|
|
292
573
|
const lines = [`# ${item.label}`, `Category: ${cat}`];
|
|
293
|
-
|
|
574
|
+
const impNames = Array.isArray(item.importName) ? item.importName : (item.importName ? [item.importName] : []);
|
|
575
|
+
for (const n of impNames) lines.push(`Import: ${n} from '${item.importFrom || 'varmory'}'`);
|
|
294
576
|
if (item.template) lines.push('', '## Template', '```html', item.template, '```');
|
|
295
577
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
296
578
|
}
|
|
@@ -299,6 +581,9 @@ export default function attachShowcase(server, options = {}) {
|
|
|
299
581
|
},
|
|
300
582
|
);
|
|
301
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.
|
|
302
587
|
server.tool(
|
|
303
588
|
'get_api',
|
|
304
589
|
'Get the API definition (props, slots, events) for a component',
|
|
@@ -310,22 +595,55 @@ export default function attachShowcase(server, options = {}) {
|
|
|
310
595
|
return { content: [{ type: 'text', text: `No API definition found for "${name}". Available: ${Object.keys(definitions).join(', ')}` }] };
|
|
311
596
|
}
|
|
312
597
|
const lines = [`# ${name} API`, '', '## Props', formatProps(def)];
|
|
313
|
-
|
|
598
|
+
|
|
599
|
+
if (def.slots && Object.keys(def.slots).length) {
|
|
314
600
|
lines.push('', '## Slots');
|
|
315
601
|
for (const [slot, info] of Object.entries(def.slots)) {
|
|
316
|
-
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
|
+
}
|
|
317
609
|
}
|
|
318
610
|
}
|
|
319
|
-
|
|
611
|
+
|
|
612
|
+
if (def.events && Object.keys(def.events).length) {
|
|
320
613
|
lines.push('', '## Events');
|
|
321
614
|
for (const [event, info] of Object.entries(def.events)) {
|
|
322
|
-
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
|
+
}
|
|
323
639
|
}
|
|
324
640
|
}
|
|
325
641
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
326
642
|
},
|
|
327
643
|
);
|
|
328
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.
|
|
329
647
|
server.tool(
|
|
330
648
|
'search_docs',
|
|
331
649
|
'Search documentation pages by name or content',
|
|
@@ -351,6 +669,7 @@ export default function attachShowcase(server, options = {}) {
|
|
|
351
669
|
},
|
|
352
670
|
);
|
|
353
671
|
|
|
672
|
+
// Return the full raw markdown of a single doc.
|
|
354
673
|
server.tool(
|
|
355
674
|
'get_doc',
|
|
356
675
|
'Read a documentation page by name',
|