varmory 1.0.8 → 1.0.10

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.
@@ -399,6 +399,29 @@ function loadQuasarNormalizerData(rootDir) {
399
399
  return { apiExtends, mixinLookup };
400
400
  }
401
401
 
402
+ // ── Unified load pipeline ──────────────────────────────────────────────────
403
+
404
+ /**
405
+ * Assemble and normalize the three in-memory caches from a varmory root dir
406
+ * and/or an explicit file list. Shared by `attachShowcase` (long-running MCP
407
+ * server) and `exportShowcaseDocs` (one-shot static export) so both see the
408
+ * exact same view of the project.
409
+ */
410
+ function loadAll({ rootDir = null, files = [], quasar = true, maxDepth = 5 } = {}) {
411
+ const allFiles = [
412
+ ...(rootDir ? collectFilesFromRoot(rootDir, { quasar, maxDepth }) : []),
413
+ ...files,
414
+ ];
415
+ const { categories, docs, definitions } = loadFromFiles(allFiles);
416
+ const normalizerData = quasar
417
+ ? loadQuasarNormalizerData(rootDir)
418
+ : { apiExtends: {}, mixinLookup: {} };
419
+ for (const key of Object.keys(definitions)) {
420
+ definitions[key] = normalizeQuasarApi(definitions[key], normalizerData);
421
+ }
422
+ return { categories, docs, definitions };
423
+ }
424
+
402
425
  // ── Markdown formatters (used by the get_api tool) ─────────────────────────
403
426
 
404
427
  function formatType(type) {
@@ -426,6 +449,156 @@ function formatProps(apiDef) {
426
449
  return lines.join('\n');
427
450
  }
428
451
 
452
+ /**
453
+ * Render a single showcase component as markdown: heading, category, imports,
454
+ * and the `<template>` snippet in a fenced html block. Used by both the
455
+ * `get_component` MCP tool and the static exporter.
456
+ */
457
+ function renderComponentMd(item, cat) {
458
+ const lines = [`# ${item.label}`, `Category: ${cat}`];
459
+ const impNames = Array.isArray(item.importName)
460
+ ? item.importName
461
+ : (item.importName ? [item.importName] : []);
462
+ for (const n of impNames) lines.push(`Import: ${n} from '${item.importFrom || 'varmory'}'`);
463
+ if (item.template) lines.push('', '## Template', '```html', item.template, '```');
464
+ return lines.join('\n');
465
+ }
466
+
467
+ /**
468
+ * Render a component's full API (props, slots, events, methods) as markdown.
469
+ * Used by both the `get_api` MCP tool and the static exporter.
470
+ */
471
+ function renderApiMd(name, def) {
472
+ const lines = [`# ${name} API`, '', '## Props', formatProps(def)];
473
+
474
+ if (def.slots && Object.keys(def.slots).length) {
475
+ lines.push('', '## Slots');
476
+ for (const [slot, info] of Object.entries(def.slots)) {
477
+ lines.push(`- **${slot}** — ${info.desc || ''}${formatMixinTag(info)}`);
478
+ if (info.scope && Object.keys(info.scope).length) {
479
+ for (const [k, v] of Object.entries(info.scope)) {
480
+ lines.push(` - \`props.${k}\`: ${formatType(v?.type)} — ${v?.desc || ''}`);
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ if (def.events && Object.keys(def.events).length) {
487
+ lines.push('', '## Events');
488
+ for (const [event, info] of Object.entries(def.events)) {
489
+ lines.push(`- **${event}** — ${info.desc || ''}${formatMixinTag(info)}`);
490
+ if (info.params && Object.keys(info.params).length) {
491
+ for (const [k, v] of Object.entries(info.params)) {
492
+ lines.push(` - \`${k}\`: ${formatType(v?.type)} — ${v?.desc || ''}`);
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ if (def.methods && Object.keys(def.methods).length) {
499
+ lines.push('', '## Methods');
500
+ for (const [method, info] of Object.entries(def.methods)) {
501
+ lines.push(`- **${method}()** — ${info.desc || ''}${formatMixinTag(info)}`);
502
+ if (info.params && Object.keys(info.params).length) {
503
+ for (const [k, v] of Object.entries(info.params)) {
504
+ const req = v?.required ? ' *(required)*' : '';
505
+ lines.push(` - param \`${k}\`${req}: ${formatType(v?.type)} — ${v?.desc || ''}`);
506
+ }
507
+ }
508
+ if (info.returns && typeof info.returns === 'object') {
509
+ lines.push(` - returns: ${formatType(info.returns.type)} — ${info.returns.desc || ''}`);
510
+ }
511
+ }
512
+ }
513
+ return lines.join('\n');
514
+ }
515
+
516
+ // ── Doc enrichment ─────────────────────────────────────────────────────────
517
+
518
+ /**
519
+ * Scan markdown for showcase hash links (`[label](#Cat/Name)` or
520
+ * `<a href="#Cat/Name">`) and inline each referenced item's `<template>` as
521
+ * a fenced html block right after the containing block (paragraph, heading,
522
+ * list, etc.) — never mid-line.
523
+ *
524
+ * Each `Cat/Name` is injected only on its first occurrence; later references
525
+ * stay as bare links. Links inside fenced code blocks are ignored, and
526
+ * unresolved refs are silently dropped. Returns the original content
527
+ * unchanged when no refs resolve.
528
+ */
529
+ function enrichDocWithShowcaseLinks(content, categories) {
530
+ if (!content || !categories) return content;
531
+
532
+ const catKey = (cat) => {
533
+ if (categories[cat]) return cat;
534
+ const lc = cat.toLowerCase();
535
+ return Object.keys(categories).find(k => k.toLowerCase() === lc);
536
+ };
537
+
538
+ const resolve = (cat, name) => {
539
+ const key = catKey(cat);
540
+ if (!key) return null;
541
+ const lc = name.toLowerCase();
542
+ const item = categories[key].find(i =>
543
+ i.name.toLowerCase() === lc || i.label.toLowerCase() === lc,
544
+ );
545
+ if (!item || !item.template) return null;
546
+ return { cat: key, item };
547
+ };
548
+
549
+ const mdRe = /\[[^\]]+\]\(#([A-Za-z][A-Za-z0-9_-]*)\/([A-Za-z0-9_-]+)\)/g;
550
+ const htmlRe = /href\s*=\s*["']#([A-Za-z][A-Za-z0-9_-]*)\/([A-Za-z0-9_-]+)["']/g;
551
+
552
+ const out = [];
553
+ const injected = new Set();
554
+ let pending = [];
555
+ let inFence = false;
556
+
557
+ const flushPending = () => {
558
+ if (!pending.length) return;
559
+ // Ensure exactly one blank line sits between prior content and the
560
+ // first injection, regardless of whether we just pushed a blank.
561
+ if (out.length && out[out.length - 1].trim() !== '') out.push('');
562
+ for (const { cat, item } of pending) {
563
+ out.push(`<!-- showcase: ${cat}/${item.name} -->`);
564
+ out.push('```html');
565
+ out.push(item.template);
566
+ out.push('```');
567
+ out.push('');
568
+ }
569
+ pending = [];
570
+ };
571
+
572
+ for (const line of content.split('\n')) {
573
+ const isFenceMarker = /^\s*```/.test(line);
574
+ if (isFenceMarker) inFence = !inFence;
575
+
576
+ out.push(line);
577
+
578
+ if (!inFence && !isFenceMarker) {
579
+ for (const re of [mdRe, htmlRe]) {
580
+ re.lastIndex = 0;
581
+ let m;
582
+ while ((m = re.exec(line)) !== null) {
583
+ const key = `${m[1]}/${m[2]}`;
584
+ if (injected.has(key)) continue;
585
+ const res = resolve(m[1], m[2]);
586
+ if (!res) continue;
587
+ injected.add(key);
588
+ pending.push(res);
589
+ }
590
+ }
591
+ }
592
+
593
+ if (!inFence && line.trim() === '' && pending.length) flushPending();
594
+ }
595
+
596
+ // EOF flush — only kicks in when the doc has no trailing blank line.
597
+ if (pending.length) flushPending();
598
+
599
+ return out.join('\n');
600
+ }
601
+
429
602
  // ── Public entry point ─────────────────────────────────────────────────────
430
603
 
431
604
  /**
@@ -447,80 +620,115 @@ function formatProps(apiDef) {
447
620
  * but won't have access to Quasar's shared `extends` / mixin pool.
448
621
  * @param {number} [options.maxDepth=5] - How deep the `categories/` /
449
622
  * `definitions/` folder search goes under `rootDir`.
623
+ * @param {boolean} [options.inlineShowcaseExamples=true] - When true, docs
624
+ * served via `get_doc` / the `doc` resource are post-processed to
625
+ * append a `## Referenced showcase components` section with the
626
+ * `<template>` of every `#Category/Name` link they contain. Set false
627
+ * to serve raw markdown.
628
+ * @param {string|null} [options.searchIndex] - Absolute path to a pre-built
629
+ * `.vecito` search index. When provided, `search_components` and
630
+ * `search_docs` use semantic search. When omitted, defaults to
631
+ * `<rootDir>/src/public/search.vecito` if it exists, otherwise
632
+ * falls back to substring matching.
450
633
  * @returns {McpServer} The same server instance, now with resources and tools
451
634
  * attached.
452
635
  */
636
+ export { loadAll };
637
+
453
638
  export default function attachShowcase(server, options = {}) {
454
639
  const rootDir = options.rootDir || null;
455
640
  const quasar = options.quasar !== false; // default true
456
641
  const maxDepth = options.maxDepth ?? 5;
642
+ const inlineShowcaseExamples = options.inlineShowcaseExamples !== false;
457
643
 
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);
644
+ const { categories, docs, definitions } = loadAll({
645
+ rootDir,
646
+ files: options.files || [],
647
+ quasar,
648
+ maxDepth,
649
+ });
466
650
 
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);
475
- }
651
+ const serveDoc = (content) =>
652
+ inlineShowcaseExamples ? enrichDocWithShowcaseLinks(content, categories) : content;
653
+
654
+ // ── Vecito semantic search (loaded lazily from pre-built snapshot) ──
655
+ const vecitoReady = (() => {
656
+ if ('searchIndex' in options && !options.searchIndex) return null;
657
+ const snapshotPath = options.searchIndex
658
+ || (rootDir ? path.join(rootDir, 'src/public/search.vecito') : null);
659
+ if (!snapshotPath || !fs.existsSync(snapshotPath)) return null;
660
+ return import('vecito').then(({ Vecito }) => Vecito.load(snapshotPath)).catch((err) => {
661
+ console.error('[varmory] Failed to load vecito search index:', err.message);
662
+ return null;
663
+ });
664
+ })();
665
+
666
+ /** Wrap a tool handler with try/catch so failures return isError instead of crashing. */
667
+ const safeTool = (name, fn) => async (args) => {
668
+ try { return await fn(args); }
669
+ catch (err) {
670
+ console.error(`[varmory] ${name}:`, err);
671
+ return { isError: true, content: [{ type: 'text', text: `Error in ${name}: ${err.message}` }] };
672
+ }
673
+ };
476
674
 
477
675
  // ── Resources ──────────────────────────────────────────────────────
478
676
  // Resources are URI-addressable listings; MCP clients typically enumerate
479
677
  // them to discover what's available.
480
678
 
481
- server.resource('docs-list', 'showcase://docs', async () => {
482
- const names = Object.keys(docs);
483
- const text = names.map(n => `- ${n}`).join('\n');
484
- return {
485
- contents: [{ uri: 'showcase://docs', text: `Available docs:\n${text}`, mimeType: 'text/plain' }],
486
- };
487
- });
679
+ const docNames = Object.keys(docs);
680
+ const defNames = Object.keys(definitions);
681
+
682
+ server.resource(
683
+ 'docs-list',
684
+ { uri: 'showcase://docs', name: 'Documentation index', description: 'Lists all available documentation pages', mimeType: 'text/plain' },
685
+ async () => ({
686
+ contents: [{ uri: 'showcase://docs', text: `Available docs:\n${docNames.map(n => `- ${n}`).join('\n')}`, mimeType: 'text/plain' }],
687
+ }),
688
+ );
488
689
 
489
690
  server.resource(
490
691
  'doc',
491
- new ResourceTemplate('showcase://docs/{name}', { list: undefined }),
692
+ new ResourceTemplate('showcase://docs/{name}', {
693
+ list: async () => ({
694
+ resources: docNames.map(n => ({ uri: `showcase://docs/${n}`, name: n })),
695
+ }),
696
+ description: 'Read a specific documentation page by name',
697
+ }),
492
698
  async (uri, { name }) => {
493
699
  const content = docs[name];
494
- if (!content) return { contents: [] };
495
- return {
496
- contents: [{ uri: uri.href, text: content, mimeType: 'text/markdown' }],
497
- };
700
+ if (!content) {
701
+ return { contents: [{ uri: uri.href, text: `Doc "${name}" not found. Available: ${docNames.join(', ')}`, mimeType: 'text/plain' }] };
702
+ }
703
+ return { contents: [{ uri: uri.href, text: serveDoc(content), mimeType: 'text/markdown' }] };
498
704
  },
499
705
  );
500
706
 
501
- server.resource('components-list', 'showcase://components', async () => {
502
- const lines = [];
503
- for (const [cat, items] of Object.entries(categories)) {
504
- lines.push(`## ${cat}`);
505
- for (const item of items) {
506
- const impNames = Array.isArray(item.importName) ? item.importName.join(', ') : item.importName;
507
- const imp = impNames ? ` (${impNames})` : '';
508
- lines.push(`- ${item.label}${imp}`);
707
+ server.resource(
708
+ 'components-list',
709
+ { uri: 'showcase://components', name: 'Components index', description: 'Lists all showcase components grouped by category', mimeType: 'text/markdown' },
710
+ async () => {
711
+ const lines = [];
712
+ for (const [cat, items] of Object.entries(categories)) {
713
+ lines.push(`## ${cat}`);
714
+ for (const item of items) {
715
+ const impNames = Array.isArray(item.importName) ? item.importName.join(', ') : item.importName;
716
+ const imp = impNames ? ` (${impNames})` : '';
717
+ lines.push(`- ${item.label}${imp}`);
718
+ }
719
+ lines.push('');
509
720
  }
510
- lines.push('');
511
- }
512
- return {
513
- contents: [{ uri: 'showcase://components', text: lines.join('\n'), mimeType: 'text/markdown' }],
514
- };
515
- });
721
+ return { contents: [{ uri: 'showcase://components', text: lines.join('\n'), mimeType: 'text/markdown' }] };
722
+ },
723
+ );
516
724
 
517
- server.resource('definitions-list', 'showcase://definitions', async () => {
518
- const names = Object.keys(definitions);
519
- const text = names.map(n => `- ${n}`).join('\n');
520
- return {
521
- contents: [{ uri: 'showcase://definitions', text: `Available API definitions:\n${text}`, mimeType: 'text/plain' }],
522
- };
523
- });
725
+ server.resource(
726
+ 'definitions-list',
727
+ { uri: 'showcase://definitions', name: 'API definitions index', description: 'Lists all available component API definitions', mimeType: 'text/plain' },
728
+ async () => ({
729
+ contents: [{ uri: 'showcase://definitions', text: `Available API definitions:\n${defNames.map(n => `- ${n}`).join('\n')}`, mimeType: 'text/plain' }],
730
+ }),
731
+ );
524
732
 
525
733
  // ── Tools ──────────────────────────────────────────────────────────
526
734
  // Tools are functions the model can call. All of ours are read-only —
@@ -533,16 +741,23 @@ export default function attachShowcase(server, options = {}) {
533
741
  server.tool(
534
742
  'search_components',
535
743
  'Search showcase components by name or label',
536
- { query: z.string().describe('Search term to match against component names and labels') },
744
+ { query: z.string().min(1).describe('Search term to match against component names and labels') },
537
745
  readOnly,
538
- async ({ query }) => {
539
- const q = query.toLowerCase();
540
- const results = [];
541
- for (const [cat, items] of Object.entries(categories)) {
542
- for (const item of items) {
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))) {
545
- results.push({ category: cat, name: item.name, label: item.label });
746
+ safeTool('search_components', async ({ query }) => {
747
+ const vecito = vecitoReady ? await vecitoReady : null;
748
+ let results;
749
+ if (vecito) {
750
+ const hits = await vecito.search(query, { top: 15, filter: m => m.type === 'component' });
751
+ results = hits.map(h => h.metadata);
752
+ } else {
753
+ const q = query.toLowerCase();
754
+ results = [];
755
+ for (const [cat, items] of Object.entries(categories)) {
756
+ for (const item of items) {
757
+ const impNames = Array.isArray(item.importName) ? item.importName : (item.importName ? [item.importName] : []);
758
+ if (item.name.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || impNames.some(n => n.toLowerCase().includes(q))) {
759
+ results.push({ category: cat, name: item.name, label: item.label });
760
+ }
546
761
  }
547
762
  }
548
763
  }
@@ -554,7 +769,7 @@ export default function attachShowcase(server, options = {}) {
554
769
  : `No components matching "${query}"`,
555
770
  }],
556
771
  };
557
- },
772
+ }),
558
773
  );
559
774
 
560
775
  // Fetch a single showcase component's metadata + template snippet. Tries
@@ -562,23 +777,19 @@ export default function attachShowcase(server, options = {}) {
562
777
  server.tool(
563
778
  'get_component',
564
779
  'Get a showcase component\'s template code and metadata',
565
- { name: z.string().describe('Component name (e.g. "Btn", "BasicPanel")') },
780
+ { name: z.string().min(1).describe('Component name (e.g. "Btn", "BasicPanel")') },
566
781
  readOnly,
567
- async ({ name }) => {
782
+ safeTool('get_component', async ({ name }) => {
568
783
  const q = name.toLowerCase();
569
784
  for (const [cat, items] of Object.entries(categories)) {
570
785
  const impMatch = (imp) => { const names = Array.isArray(imp) ? imp : (imp ? [imp] : []); return names.some(n => n.toLowerCase() === q); };
571
786
  const item = items.find(i => i.name.toLowerCase() === q || i.label.toLowerCase() === q || impMatch(i.importName));
572
787
  if (item) {
573
- const lines = [`# ${item.label}`, `Category: ${cat}`];
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'}'`);
576
- if (item.template) lines.push('', '## Template', '```html', item.template, '```');
577
- return { content: [{ type: 'text', text: lines.join('\n') }] };
788
+ return { content: [{ type: 'text', text: renderComponentMd(item, cat) }] };
578
789
  }
579
790
  }
580
- return { content: [{ type: 'text', text: `Component "${name}" not found.` }] };
581
- },
791
+ return { isError: true, content: [{ type: 'text', text: `Component "${name}" not found.` }] };
792
+ }),
582
793
  );
583
794
 
584
795
  // Render a component's full API (props, slots, events, methods) as
@@ -587,59 +798,15 @@ export default function attachShowcase(server, options = {}) {
587
798
  server.tool(
588
799
  'get_api',
589
800
  'Get the API definition (props, slots, events) for a component',
590
- { name: z.string().describe('Component name (e.g. "QBtn", "JPanel")') },
801
+ { name: z.string().min(1).describe('Component name (e.g. "QBtn", "JPanel")') },
591
802
  readOnly,
592
- async ({ name }) => {
803
+ safeTool('get_api', async ({ name }) => {
593
804
  const def = definitions[name];
594
805
  if (!def) {
595
- return { content: [{ type: 'text', text: `No API definition found for "${name}". Available: ${Object.keys(definitions).join(', ')}` }] };
806
+ return { isError: true, content: [{ type: 'text', text: `No API definition found for "${name}". Available: ${defNames.join(', ')}` }] };
596
807
  }
597
- const lines = [`# ${name} API`, '', '## Props', formatProps(def)];
598
-
599
- if (def.slots && Object.keys(def.slots).length) {
600
- lines.push('', '## Slots');
601
- for (const [slot, info] of Object.entries(def.slots)) {
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
- }
609
- }
610
- }
611
-
612
- if (def.events && Object.keys(def.events).length) {
613
- lines.push('', '## Events');
614
- for (const [event, info] of Object.entries(def.events)) {
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
- }
639
- }
640
- }
641
- return { content: [{ type: 'text', text: lines.join('\n') }] };
642
- },
808
+ return { content: [{ type: 'text', text: renderApiMd(name, def) }] };
809
+ }),
643
810
  );
644
811
 
645
812
  // Simple full-text search across docs. Returns the first matching line as
@@ -647,15 +814,29 @@ export default function attachShowcase(server, options = {}) {
647
814
  server.tool(
648
815
  'search_docs',
649
816
  'Search documentation pages by name or content',
650
- { query: z.string().describe('Search term to match against doc names and content') },
817
+ { query: z.string().min(1).describe('Search term to match against doc names and content') },
651
818
  readOnly,
652
- async ({ query }) => {
653
- const q = query.toLowerCase();
654
- const results = [];
655
- for (const [name, content] of Object.entries(docs)) {
656
- if (name.toLowerCase().includes(q) || content.toLowerCase().includes(q)) {
657
- const snippet = content.split('\n').find(l => l.toLowerCase().includes(q))?.trim() || '';
658
- results.push({ name, snippet: snippet.slice(0, 120) });
819
+ safeTool('search_docs', async ({ query }) => {
820
+ const vecito = vecitoReady ? await vecitoReady : null;
821
+ let results;
822
+ if (vecito) {
823
+ const { Highlighter } = await import('vecito');
824
+ const hits = await vecito.search(query, { top: 5, filter: m => m.type === 'doc', matchedTerms: true });
825
+ results = hits.map(h => {
826
+ const content = docs[h.metadata.name] || '';
827
+ const snippet = h.matchedTerms
828
+ ? Highlighter.snippet(content, h.matchedTerms)
829
+ : content.slice(0, 120);
830
+ return { name: h.metadata.name, snippet: snippet.slice(0, 120) };
831
+ });
832
+ } else {
833
+ const q = query.toLowerCase();
834
+ results = [];
835
+ for (const [name, content] of Object.entries(docs)) {
836
+ if (name.toLowerCase().includes(q) || content.toLowerCase().includes(q)) {
837
+ const snippet = content.split('\n').find(l => l.toLowerCase().includes(q))?.trim() || '';
838
+ results.push({ name, snippet: snippet.slice(0, 120) });
839
+ }
659
840
  }
660
841
  }
661
842
  return {
@@ -666,23 +847,144 @@ export default function attachShowcase(server, options = {}) {
666
847
  : `No docs matching "${query}"`,
667
848
  }],
668
849
  };
669
- },
850
+ }),
670
851
  );
671
852
 
672
853
  // Return the full raw markdown of a single doc.
673
854
  server.tool(
674
855
  'get_doc',
675
856
  'Read a documentation page by name',
676
- { name: z.string().describe('Doc page name (e.g. "README", "THEMING", "SHOWCASE")') },
857
+ { name: z.string().min(1).describe('Doc page name (e.g. "README", "THEMING", "SHOWCASE")') },
677
858
  readOnly,
678
- async ({ name }) => {
859
+ safeTool('get_doc', async ({ name }) => {
679
860
  const content = docs[name];
680
861
  if (!content) {
681
- return { content: [{ type: 'text', text: `Doc "${name}" not found. Available: ${Object.keys(docs).join(', ')}` }] };
862
+ return { isError: true, content: [{ type: 'text', text: `Doc "${name}" not found. Available: ${docNames.join(', ')}` }] };
682
863
  }
683
- return { content: [{ type: 'text', text: content }] };
684
- },
864
+ return { content: [{ type: 'text', text: serveDoc(content) }] };
865
+ }),
685
866
  );
686
867
 
687
868
  return server;
688
869
  }
870
+
871
+ // ── Static export ──────────────────────────────────────────────────────────
872
+
873
+ /**
874
+ * Export the docs, components, and API definitions as standalone markdown
875
+ * files under `outDir`. Useful for publishing a browsable snapshot of the
876
+ * showcase alongside (or instead of) the live MCP server.
877
+ *
878
+ * Output layout:
879
+ * <outDir>/README.md (only when a source README.md exists;
880
+ * the original content plus an
881
+ * appended index of everything below)
882
+ * <outDir>/docs/<name>.md (each markdown doc, optionally
883
+ * enriched with showcase templates)
884
+ * <outDir>/components/<Cat>/<Name>.md (each .vue showcase rendered)
885
+ * <outDir>/definitions/<Name>.md (each API definition rendered)
886
+ *
887
+ * @param {object} options
888
+ * @param {string} options.rootDir - varmory project root (required).
889
+ * @param {string} [options.outDir] - Target directory. Default: `<rootDir>/dist/docs`.
890
+ * @param {boolean} [options.quasar=true] - Include Quasar's component JSONs.
891
+ * @param {number} [options.maxDepth=5] - Search depth for categories/definitions folders.
892
+ * @param {boolean} [options.inlineShowcaseExamples=true] - Enrich docs with referenced showcase templates.
893
+ * @param {boolean} [options.clean=true] - Remove `outDir` before writing.
894
+ * Only permitted when `outDir` resolves inside `rootDir`; otherwise throws.
895
+ * @returns {{ outDir: string, docs: number, components: number, definitions: number, cleaned: boolean }}
896
+ */
897
+ export function exportShowcaseDocs(options = {}) {
898
+ const {
899
+ rootDir,
900
+ quasar = true,
901
+ maxDepth = 5,
902
+ inlineShowcaseExamples = true,
903
+ clean = true,
904
+ } = options;
905
+ if (!rootDir) throw new Error('exportShowcaseDocs: `rootDir` is required');
906
+
907
+ const resolvedRoot = path.resolve(rootDir);
908
+ const resolvedOut = path.resolve(options.outDir || path.join(resolvedRoot, 'dist/docs'));
909
+
910
+ let cleaned = false;
911
+ if (clean) {
912
+ // Safety belt: refuse to clean a path that sits outside rootDir so a
913
+ // misconfigured outDir can't wipe unintended directories.
914
+ const rel = path.relative(resolvedRoot, resolvedOut);
915
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
916
+ throw new Error(
917
+ `exportShowcaseDocs: refusing to clean outDir (${resolvedOut}) — must be inside rootDir (${resolvedRoot}). Pass clean: false to skip.`,
918
+ );
919
+ }
920
+ fs.rmSync(resolvedOut, { recursive: true, force: true });
921
+ cleaned = true;
922
+ }
923
+
924
+ const { categories, docs, definitions } = loadAll({ rootDir: resolvedRoot, quasar, maxDepth });
925
+
926
+ fs.mkdirSync(resolvedOut, { recursive: true });
927
+ const docsOut = path.join(resolvedOut, 'docs');
928
+ const componentsOut = path.join(resolvedOut, 'components');
929
+ const definitionsOut = path.join(resolvedOut, 'definitions');
930
+
931
+ // Docs. README is skipped here — it becomes the top-level index below.
932
+ let docCount = 0;
933
+ const docNames = Object.keys(docs).filter(n => n !== 'README').sort();
934
+ if (docNames.length) fs.mkdirSync(docsOut, { recursive: true });
935
+ for (const name of docNames) {
936
+ const body = inlineShowcaseExamples
937
+ ? enrichDocWithShowcaseLinks(docs[name], categories)
938
+ : docs[name];
939
+ fs.writeFileSync(path.join(docsOut, `${name}.md`), body);
940
+ docCount++;
941
+ }
942
+
943
+ // Components.
944
+ let componentCount = 0;
945
+ const catNames = Object.keys(categories).sort();
946
+ if (catNames.length) fs.mkdirSync(componentsOut, { recursive: true });
947
+ for (const cat of catNames) {
948
+ const catDir = path.join(componentsOut, cat);
949
+ fs.mkdirSync(catDir, { recursive: true });
950
+ for (const item of categories[cat]) {
951
+ fs.writeFileSync(path.join(catDir, `${item.name}.md`), renderComponentMd(item, cat));
952
+ componentCount++;
953
+ }
954
+ }
955
+
956
+ // API definitions.
957
+ let definitionCount = 0;
958
+ const defNames = Object.keys(definitions).sort();
959
+ if (defNames.length) fs.mkdirSync(definitionsOut, { recursive: true });
960
+ for (const name of defNames) {
961
+ fs.writeFileSync(path.join(definitionsOut, `${name}.md`), renderApiMd(name, definitions[name]));
962
+ definitionCount++;
963
+ }
964
+
965
+ // Index README (only when source README exists).
966
+ if (docs['README']) {
967
+ const parts = [docs['README'].trimEnd(), '', '## Docs', ''];
968
+ for (const name of docNames) parts.push(`- [${name}](docs/${name}.md)`);
969
+ parts.push('', '## Components', '');
970
+ for (const cat of catNames) {
971
+ parts.push(`### ${cat}`, '');
972
+ for (const item of categories[cat]) {
973
+ parts.push(`- [${item.label}](components/${cat}/${item.name}.md)`);
974
+ }
975
+ parts.push('');
976
+ }
977
+ parts.push('## API definitions', '');
978
+ for (const name of defNames) parts.push(`- [${name}](definitions/${name}.md)`);
979
+ parts.push('');
980
+ fs.writeFileSync(path.join(resolvedOut, 'README.md'), parts.join('\n'));
981
+ }
982
+
983
+ return {
984
+ outDir: resolvedOut,
985
+ docs: docCount,
986
+ components: componentCount,
987
+ definitions: definitionCount,
988
+ cleaned,
989
+ };
990
+ }