feat(docs): doc-sections system, crdt package, MCP server, and responsive fixes
Adds a hand-authored .vue doc-sections system (intro + guide pages per package, #docs/sections map, sidebar Guide group, client-side TOC), registers @robonen/crdt, renders demos client-only, base64-encodes the server-metadata virtual, plus the MCP docs endpoint and responsive/overflow fixes across pages and tables.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">import type { Component } from 'vue';
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
component: Component;
|
component: Component;
|
||||||
@@ -18,9 +19,20 @@ watch(showSource, async (show) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded-xl border border-(--border) overflow-hidden">
|
<div class="rounded-xl border border-(--border) overflow-hidden">
|
||||||
<!-- Live demo -->
|
<!-- Live demo — client-only: demos are interactive and use browser APIs,
|
||||||
<div class="p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32">
|
so they must not be instantiated during SSR/prerender. -->
|
||||||
|
<div class="p-4 sm:p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32">
|
||||||
|
<ClientOnly>
|
||||||
<component :is="component" />
|
<component :is="component" />
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-(--fg-subtle)">
|
||||||
|
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Loading demo…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Source toggle bar -->
|
<!-- Source toggle bar -->
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="emits.length > 0" class="overflow-hidden rounded-xl border border-(--border)">
|
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
|
||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-(--bg-subtle) text-left">
|
<tr class="bg-(--bg-subtle) text-left">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="params.length > 0" class="overflow-hidden rounded-xl border border-(--border)">
|
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
|
||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-(--bg-subtle) text-left">
|
<tr class="bg-(--bg-subtle) text-left">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="properties.length > 0" class="overflow-hidden rounded-xl border border-(--border)">
|
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
|
||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-(--bg-subtle) text-left">
|
<tr class="bg-(--bg-subtle) text-left">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import metadata from '#docs/metadata';
|
|||||||
import type {
|
import type {
|
||||||
CategoryMeta,
|
CategoryMeta,
|
||||||
ComponentMeta,
|
ComponentMeta,
|
||||||
|
DocSection,
|
||||||
DocsMetadata,
|
DocsMetadata,
|
||||||
GuideSection,
|
GuideSection,
|
||||||
ItemMeta,
|
ItemMeta,
|
||||||
@@ -13,7 +14,8 @@ import type {
|
|||||||
export type DocEntry
|
export type DocEntry
|
||||||
= | { kind: 'api'; pkg: PackageMeta; category: CategoryMeta; item: ItemMeta }
|
= | { kind: 'api'; pkg: PackageMeta; category: CategoryMeta; item: ItemMeta }
|
||||||
| { kind: 'components'; pkg: PackageMeta; component: ComponentMeta }
|
| { kind: 'components'; pkg: PackageMeta; component: ComponentMeta }
|
||||||
| { kind: 'guide'; pkg: PackageMeta; section: GuideSection };
|
| { kind: 'guide'; pkg: PackageMeta; section: GuideSection }
|
||||||
|
| { kind: 'doc'; pkg: PackageMeta; section: DocSection };
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
pkg: PackageMeta;
|
pkg: PackageMeta;
|
||||||
@@ -62,11 +64,25 @@ export function useDocs() {
|
|||||||
return pkg.sections.length;
|
return pkg.sections.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The hand-authored intro section for a package, if any. */
|
||||||
|
function getIntro(pkg: PackageMeta): DocSection | undefined {
|
||||||
|
return pkg.docs.find(s => s.isIntro);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-intro doc sections (the "Guide" list shown in the sidebar). */
|
||||||
|
function getDocSections(pkg: PackageMeta): DocSection[] {
|
||||||
|
return pkg.docs.filter(s => !s.isIntro);
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve any `/:package/:slug` route to a normalised entry. */
|
/** Resolve any `/:package/:slug` route to a normalised entry. */
|
||||||
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
|
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
|
||||||
const pkg = getPackage(packageSlug);
|
const pkg = getPackage(packageSlug);
|
||||||
if (!pkg) return undefined;
|
if (!pkg) return undefined;
|
||||||
|
|
||||||
|
// Hand-authored doc sections take precedence over auto-generated leaves.
|
||||||
|
const docSection = pkg.docs.find(s => !s.isIntro && s.slug === slug);
|
||||||
|
if (docSection) return { kind: 'doc', pkg, section: docSection };
|
||||||
|
|
||||||
if (pkg.kind === 'api') {
|
if (pkg.kind === 'api') {
|
||||||
for (const category of pkg.categories) {
|
for (const category of pkg.categories) {
|
||||||
const item = category.items.find(i => i.slug === slug);
|
const item = category.items.find(i => i.slug === slug);
|
||||||
@@ -139,6 +155,8 @@ export function useDocs() {
|
|||||||
countEntries,
|
countEntries,
|
||||||
resolveEntry,
|
resolveEntry,
|
||||||
firstEntrySlug,
|
firstEntrySlug,
|
||||||
|
getIntro,
|
||||||
|
getDocSections,
|
||||||
search,
|
search,
|
||||||
getTotalItems,
|
getTotalItems,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ export interface Heading {
|
|||||||
export function slugHeading(text: string): string {
|
export function slugHeading(text: string): string {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/`/g, '')
|
.replaceAll('`', '')
|
||||||
.replace(/[^\w\s-]/g, '')
|
.replaceAll(/[^\w\s-]/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, '-');
|
.replaceAll(/\s+/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect h2/h3 headings for the table of contents. */
|
/** Collect h2/h3 headings for the table of contents. */
|
||||||
@@ -28,11 +28,13 @@ export function extractHeadings(markdown: string): Heading[] {
|
|||||||
}
|
}
|
||||||
if (inFence) continue;
|
if (inFence) continue;
|
||||||
|
|
||||||
const m = line.match(/^(#{2,3})\s+(.+?)\s*#*$/);
|
const m = line.match(/^(#{2,3})\s+(\S.*)$/);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
|
|
||||||
const depth = m[1]!.length;
|
const depth = m[1]!.length;
|
||||||
const text = m[2]!.replace(/`/g, '').trim();
|
// Strip an optional ATX closing run (a single space then trailing `#`s) without
|
||||||
|
// a backtracking-prone pattern, then drop inline code ticks.
|
||||||
|
const text = m[2]!.replace(/ #+ *$/, '').replaceAll('`', '').trim();
|
||||||
let id = slugHeading(text);
|
let id = slugHeading(text);
|
||||||
const count = seen.get(id) ?? 0;
|
const count = seen.get(id) ?? 0;
|
||||||
seen.set(id, count + 1);
|
seen.set(id, count + 1);
|
||||||
@@ -51,7 +53,7 @@ export function renderMarkdown(markdown: string): string {
|
|||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
renderer.heading = function ({ tokens, depth }) {
|
renderer.heading = function ({ tokens, depth }) {
|
||||||
const inner = this.parser.parseInline(tokens);
|
const inner = this.parser.parseInline(tokens);
|
||||||
const plain = inner.replace(/<[^>]+>/g, '');
|
const plain = inner.replaceAll(/<[^>]+>/g, '');
|
||||||
let id = slugHeading(plain);
|
let id = slugHeading(plain);
|
||||||
const count = seen.get(id) ?? 0;
|
const count = seen.get(id) ?? 0;
|
||||||
seen.set(id, count + 1);
|
seen.set(id, count + 1);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">const { getGroupedPackages, getPackage } = useDocs();
|
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs();
|
||||||
const groups = getGroupedPackages();
|
const groups = getGroupedPackages();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -91,6 +91,39 @@ watch(() => route.path, () => {
|
|||||||
|
|
||||||
<!-- Expanded tree for the current package -->
|
<!-- Expanded tree for the current package -->
|
||||||
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1 mb-2 ml-2 pl-3 border-l border-(--border)">
|
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1 mb-2 ml-2 pl-3 border-l border-(--border)">
|
||||||
|
<!-- Hand-authored guide sections (intro + prose pages) -->
|
||||||
|
<div v-if="currentPackage.docs.length" class="mb-2">
|
||||||
|
<div class="text-[11px] font-medium text-(--fg-subtle) py-1 px-1">Guide</div>
|
||||||
|
<ul>
|
||||||
|
<li v-if="getIntro(currentPackage)">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/${pkg.slug}`"
|
||||||
|
:class="[
|
||||||
|
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||||
|
route.path === `/${pkg.slug}`
|
||||||
|
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
||||||
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Introduction
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li v-for="s in getDocSections(currentPackage)" :key="s.slug">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/${pkg.slug}/${s.slug}`"
|
||||||
|
:class="[
|
||||||
|
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||||
|
isActive(pkg.slug, s.slug)
|
||||||
|
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
||||||
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ s.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- api -->
|
<!-- api -->
|
||||||
<template v-if="currentPackage.kind === 'api'">
|
<template v-if="currentPackage.kind === 'api'">
|
||||||
<div v-for="cat in currentPackage.categories" :key="cat.slug" class="mb-2">
|
<div v-for="cat in currentPackage.categories" :key="cat.slug" class="mb-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">import { demos } from '#docs/demos';
|
<script setup lang="ts">import { demos } from '#docs/demos';
|
||||||
|
import { sections } from '#docs/sections';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { resolveEntry } = useDocs();
|
const { resolveEntry } = useDocs();
|
||||||
@@ -18,6 +19,35 @@ if (!entry.value) {
|
|||||||
const pkg = computed(() => entry.value!.pkg);
|
const pkg = computed(() => entry.value!.pkg);
|
||||||
|
|
||||||
const demoComponent = computed(() => demos[`${packageSlug.value}/${utilitySlug.value}`] ?? null);
|
const demoComponent = computed(() => demos[`${packageSlug.value}/${utilitySlug.value}`] ?? null);
|
||||||
|
const sectionComponent = computed(() => sections[`${packageSlug.value}/${utilitySlug.value}`] ?? null);
|
||||||
|
|
||||||
|
// ── Doc sections: client-side TOC built from the rendered headings ───────────
|
||||||
|
const docRoot = ref<HTMLElement | null>(null);
|
||||||
|
const docToc = ref<Array<{ id: string; text: string; depth: number }>>([]);
|
||||||
|
|
||||||
|
function buildDocToc() {
|
||||||
|
const el = docRoot.value;
|
||||||
|
if (!el) return;
|
||||||
|
docToc.value = Array.from(el.querySelectorAll('h2, h3')).map((h) => {
|
||||||
|
if (!h.id) {
|
||||||
|
h.id = (h.textContent ?? '')
|
||||||
|
.toLowerCase().trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
return { id: h.id, text: h.textContent ?? '', depth: h.tagName === 'H2' ? 2 : 3 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = docRoot.value;
|
||||||
|
if (!el) return;
|
||||||
|
buildDocToc();
|
||||||
|
// The section is an async component — rebuild once its content mounts.
|
||||||
|
const observer = new MutationObserver(() => buildDocToc());
|
||||||
|
observer.observe(el, { childList: true, subtree: true });
|
||||||
|
onScopeDispose(() => observer.disconnect());
|
||||||
|
});
|
||||||
|
|
||||||
function ghUrl(path: string) {
|
function ghUrl(path: string) {
|
||||||
return `https://github.com/robonen/tools/blob/master/${path}`;
|
return `https://github.com/robonen/tools/blob/master/${path}`;
|
||||||
@@ -28,6 +58,7 @@ const title = computed(() => {
|
|||||||
const e = entry.value!;
|
const e = entry.value!;
|
||||||
if (e.kind === 'api') return e.item.name;
|
if (e.kind === 'api') return e.item.name;
|
||||||
if (e.kind === 'components') return e.component.name;
|
if (e.kind === 'components') return e.component.name;
|
||||||
|
if (e.kind === 'doc') return e.section.title;
|
||||||
return e.section.title;
|
return e.section.title;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +77,9 @@ const toc = computed(() => {
|
|||||||
if (e.kind === 'guide') {
|
if (e.kind === 'guide') {
|
||||||
return extractHeadings(e.section.markdown).map(h => ({ id: h.id, text: h.text, depth: h.depth }));
|
return extractHeadings(e.section.markdown).map(h => ({ id: h.id, text: h.text, depth: h.depth }));
|
||||||
}
|
}
|
||||||
|
if (e.kind === 'doc') {
|
||||||
|
return docToc.value;
|
||||||
|
}
|
||||||
if (e.kind === 'components') {
|
if (e.kind === 'components') {
|
||||||
return e.component.parts.map(p => ({ id: p.name.toLowerCase(), text: p.name, depth: 2 }));
|
return e.component.parts.map(p => ({ id: p.name.toLowerCase(), text: p.name, depth: 2 }));
|
||||||
}
|
}
|
||||||
@@ -82,7 +116,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
|||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||||
<DocsBadge :kind="entry.item.kind" size="md" />
|
<DocsBadge :kind="entry.item.kind" size="md" />
|
||||||
<h1 class="text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
<h1 class="min-w-0 break-words text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
||||||
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
||||||
<DocsTag v-if="entry.item.hasTests" label="tested" variant="test" />
|
<DocsTag v-if="entry.item.hasTests" label="tested" variant="test" />
|
||||||
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
|
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
|
||||||
@@ -194,10 +228,17 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
|||||||
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ── GUIDE ──────────────────────────────────────────────────────── -->
|
<!-- ── GUIDE (Markdown) ───────────────────────────────────────────── -->
|
||||||
<template v-else>
|
<template v-else-if="entry.kind === 'guide'">
|
||||||
<DocsMarkdown :source="entry.section.markdown" />
|
<DocsMarkdown :source="entry.section.markdown" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ── DOC SECTION (hand-authored .vue) ───────────────────────────── -->
|
||||||
|
<template v-else-if="entry.kind === 'doc'">
|
||||||
|
<div ref="docRoot" class="docs-section">
|
||||||
|
<component :is="sectionComponent" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Right rail TOC -->
|
<!-- Right rail TOC -->
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">const route = useRoute();
|
<script setup lang="ts">import { sections } from '#docs/sections';
|
||||||
const { getPackage, countEntries } = useDocs();
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { getPackage, countEntries, getIntro } = useDocs();
|
||||||
|
|
||||||
const slug = computed(() => route.params.package as string);
|
const slug = computed(() => route.params.package as string);
|
||||||
const pkg = computed(() => getPackage(slug.value));
|
const pkg = computed(() => getPackage(slug.value));
|
||||||
@@ -8,6 +10,10 @@ if (!pkg.value) {
|
|||||||
throw createError({ statusCode: 404, message: `Package "${slug.value}" not found` });
|
throw createError({ statusCode: 404, message: `Package "${slug.value}" not found` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hand-authored intro (docs/intro.vue) renders as the package hero when present.
|
||||||
|
const intro = computed(() => getIntro(pkg.value!));
|
||||||
|
const introComponent = computed(() => intro.value ? sections[`${slug.value}/introduction`] ?? null : null);
|
||||||
|
|
||||||
useHead({ title: `${pkg.value.name} — @robonen/tools` });
|
useHead({ title: `${pkg.value.name} — @robonen/tools` });
|
||||||
|
|
||||||
const kindLabel = computed(() => ({
|
const kindLabel = computed(() => ({
|
||||||
@@ -27,9 +33,14 @@ const otherSections = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="pkg" class="max-w-3xl">
|
<div v-if="pkg" class="max-w-3xl">
|
||||||
<!-- Header -->
|
<!-- Hand-authored intro hero (docs/intro.vue) -->
|
||||||
<header class="mb-8 pb-8 border-b border-(--border)">
|
<section v-if="introComponent" class="docs-section mb-12">
|
||||||
<div class="flex items-center gap-2.5 mb-2">
|
<component :is="introComponent" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Auto header (shown only when there's no hand-authored intro) -->
|
||||||
|
<header v-else class="mb-8 pb-8 border-b border-(--border)">
|
||||||
|
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||||
<h1 class="font-mono text-2xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
|
<h1 class="font-mono text-2xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
|
||||||
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
|
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +55,11 @@ const otherSections = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- When an intro replaces the header, label the auto-generated reference -->
|
||||||
|
<h2 v-if="introComponent && pkg.kind === 'api'" class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4 pt-2">
|
||||||
|
API Reference
|
||||||
|
</h2>
|
||||||
|
|
||||||
<!-- API: categories of items -->
|
<!-- API: categories of items -->
|
||||||
<template v-if="pkg.kind === 'api'">
|
<template v-if="pkg.kind === 'api'">
|
||||||
<section v-for="category in pkg.categories" :key="category.slug" class="mb-10">
|
<section v-for="category in pkg.categories" :key="category.slug" class="mb-10">
|
||||||
@@ -51,7 +67,7 @@ const otherSections = computed(() =>
|
|||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
<span class="ml-1 text-(--fg-subtle) normal-case font-normal">· {{ category.items.length }}</span>
|
<span class="ml-1 text-(--fg-subtle) normal-case font-normal">· {{ category.items.length }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid gap-2">
|
<div class="grid grid-cols-1 gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="item in category.items"
|
v-for="item in category.items"
|
||||||
:key="item.slug"
|
:key="item.slug"
|
||||||
@@ -81,7 +97,7 @@ const otherSections = computed(() =>
|
|||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
||||||
All components <span class="normal-case font-normal">· {{ pkg.components.length }}</span>
|
All components <span class="normal-case font-normal">· {{ pkg.components.length }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="c in pkg.components"
|
v-for="c in pkg.components"
|
||||||
:key="c.slug"
|
:key="c.slug"
|
||||||
@@ -113,7 +129,7 @@ const otherSections = computed(() =>
|
|||||||
<DocsMarkdown v-if="overview" :source="overview.markdown" />
|
<DocsMarkdown v-if="overview" :source="overview.markdown" />
|
||||||
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
|
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">Sections</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">Sections</h2>
|
||||||
<div class="grid gap-2 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="s in otherSections"
|
v-for="s in otherSections"
|
||||||
:key="s.slug"
|
:key="s.slug"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ useHead({ title: '@robonen/tools — Documentation' });
|
|||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
||||||
{{ grp.label }}
|
{{ grp.label }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="pkg in grp.packages"
|
v-for="pkg in grp.packages"
|
||||||
:key="pkg.slug"
|
:key="pkg.slug"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
CategoryMeta,
|
CategoryMeta,
|
||||||
ComponentMeta,
|
ComponentMeta,
|
||||||
ComponentPartMeta,
|
ComponentPartMeta,
|
||||||
|
DocSection,
|
||||||
DocsMetadata,
|
DocsMetadata,
|
||||||
EmitMeta,
|
EmitMeta,
|
||||||
GuideSection,
|
GuideSection,
|
||||||
@@ -56,6 +57,7 @@ const PACKAGES: PackageConfig[] = [
|
|||||||
{ path: 'core/platform', slug: 'platform', kind: 'api', group: 'core' },
|
{ path: 'core/platform', slug: 'platform', kind: 'api', group: 'core' },
|
||||||
{ path: 'core/fetch', slug: 'fetch', kind: 'api', group: 'core' },
|
{ path: 'core/fetch', slug: 'fetch', kind: 'api', group: 'core' },
|
||||||
{ path: 'core/encoding', slug: 'encoding', kind: 'api', group: 'core' },
|
{ path: 'core/encoding', slug: 'encoding', kind: 'api', group: 'core' },
|
||||||
|
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
|
||||||
// ── vue ──
|
// ── vue ──
|
||||||
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
|
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
|
||||||
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
|
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
|
||||||
@@ -72,8 +74,8 @@ const PACKAGES: PackageConfig[] = [
|
|||||||
|
|
||||||
function toKebabCase(str: string): string {
|
function toKebabCase(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||||
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
.replaceAll(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +377,7 @@ function extractTypeAlias(typeAlias: TypeAliasDeclaration, sourceFilePath: strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited: Set<string> = new Set()): ItemMeta[] {
|
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited = new Set<string>()): ItemMeta[] {
|
||||||
const filePath = sourceFile.getFilePath();
|
const filePath = sourceFile.getFilePath();
|
||||||
if (visited.has(filePath)) return [];
|
if (visited.has(filePath)) return [];
|
||||||
visited.add(filePath);
|
visited.add(filePath);
|
||||||
@@ -656,7 +658,7 @@ function readPartOrder(indexPath: string): string[] {
|
|||||||
if (!existsSync(indexPath)) return [];
|
if (!existsSync(indexPath)) return [];
|
||||||
const src = readFileSync(indexPath, 'utf-8');
|
const src = readFileSync(indexPath, 'utf-8');
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const re = /export\s*\{\s*default\s+as\s+(\w+)\s*\}\s*from\s*['"]\.\/([\w.-]+)\.vue['"]/g;
|
const re = /export\s*\{\s*default\s+as\s+(\w+)\s*\}\s*from\s*['"]\.\/[\w.-]+\.vue['"]/g;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(src)) !== null) order.push(m[1]!);
|
while ((m = re.exec(src)) !== null) order.push(m[1]!);
|
||||||
return order;
|
return order;
|
||||||
@@ -751,7 +753,7 @@ function resolveGuideFiles(pkgDir: string, patterns: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function titleFromMarkdown(md: string, fallback: string): string {
|
function titleFromMarkdown(md: string, fallback: string): string {
|
||||||
const m = md.match(/^\s*#\s+(.+)$/m);
|
const m = md.match(/^\s*#\s+(\S.*)$/m);
|
||||||
return m ? m[1]!.trim() : fallback;
|
return m ? m[1]!.trim() : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +785,65 @@ function buildGuideSections(pkgDir: string, patterns: string[], pkgDescription:
|
|||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hand-authored .vue doc sections ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Read an optional `<!-- key: value -->` directive from a doc SFC. */
|
||||||
|
function readDocDirective(src: string, key: string): string | undefined {
|
||||||
|
const m = src.match(new RegExp(`<!--\\s*${key}\\s*:\\s*([^]*?)\\s*-->`));
|
||||||
|
return m ? m[1]!.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeTitle(slug: string): string {
|
||||||
|
return slug
|
||||||
|
.split(/[-_]/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover hand-authored documentation pages from `<pkg>/docs/*.vue`.
|
||||||
|
* - `intro.vue` becomes the package landing (isIntro, sorted first).
|
||||||
|
* - Other files are ordered by a `<!-- order: N -->` directive or a numeric
|
||||||
|
* filename prefix (`02-concepts.vue`); titles come from `<!-- title: … -->`
|
||||||
|
* or the humanized filename.
|
||||||
|
*/
|
||||||
|
function buildDocSections(pkgDir: string): DocSection[] {
|
||||||
|
const docsDir = resolve(pkgDir, 'docs');
|
||||||
|
if (!existsSync(docsDir)) return [];
|
||||||
|
|
||||||
|
const sections: DocSection[] = [];
|
||||||
|
for (const file of readdirSync(docsDir)) {
|
||||||
|
if (!file.endsWith('.vue')) continue;
|
||||||
|
|
||||||
|
const full = resolve(docsDir, file);
|
||||||
|
const src = readFileSync(full, 'utf-8');
|
||||||
|
const base = file.replace(/\.vue$/, '');
|
||||||
|
const isIntro = base === 'intro';
|
||||||
|
|
||||||
|
// Optional numeric prefix on the filename, e.g. "02-concepts" or "02.concepts".
|
||||||
|
const prefixed = base.match(/^(\d+)[-.](.+)$/);
|
||||||
|
const rawName = prefixed ? prefixed[2]! : base;
|
||||||
|
|
||||||
|
const orderDirective = readDocDirective(src, 'order');
|
||||||
|
const order = isIntro
|
||||||
|
? -1
|
||||||
|
: orderDirective !== undefined
|
||||||
|
? Number(orderDirective)
|
||||||
|
: prefixed
|
||||||
|
? Number(prefixed[1])
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
const slug = isIntro ? 'introduction' : slugify(rawName);
|
||||||
|
const title = readDocDirective(src, 'title')
|
||||||
|
?? (isIntro ? 'Introduction' : humanizeTitle(rawName));
|
||||||
|
|
||||||
|
sections.push({ title, slug, order, isIntro, sourcePath: relative(ROOT, full) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Package Extraction ─────────────────────────────────────────────────────────
|
// ── Package Extraction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function extractPackage(config: PackageConfig): PackageMeta | null {
|
function extractPackage(config: PackageConfig): PackageMeta | null {
|
||||||
@@ -807,6 +868,7 @@ function extractPackage(config: PackageConfig): PackageMeta | null {
|
|||||||
categories: [],
|
categories: [],
|
||||||
components: [],
|
components: [],
|
||||||
sections: [],
|
sections: [],
|
||||||
|
docs: buildDocSections(pkgDir),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.kind === 'api') {
|
if (config.kind === 'api') {
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ export default defineNuxtModule({
|
|||||||
// Register the alias for the virtual module
|
// Register the alias for the virtual module
|
||||||
nuxt.options.alias['#docs/metadata'] = resolve(nuxt.options.buildDir, 'docs-metadata');
|
nuxt.options.alias['#docs/metadata'] = resolve(nuxt.options.buildDir, 'docs-metadata');
|
||||||
|
|
||||||
|
// Expose the same metadata to Nitro so server routes (e.g. the MCP endpoint
|
||||||
|
// at `server/routes/mcp.post.ts`) can import it without re-running extraction.
|
||||||
|
nuxt.hook('nitro:config', (nitroConfig: { virtual?: Record<string, string | (() => string)> }) => {
|
||||||
|
nitroConfig.virtual ??= {};
|
||||||
|
// Base64-encode the payload so Nitro's build-time text replacements (e.g.
|
||||||
|
// `typeof window` → "undefined") can't corrupt source snippets embedded in
|
||||||
|
// the metadata JSON (demo sources, examples, type signatures).
|
||||||
|
const encoded = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64');
|
||||||
|
nitroConfig.virtual['#docs/server-metadata'] = () => `export default JSON.parse(Buffer.from(${JSON.stringify(encoded)}, 'base64').toString('utf8'))`;
|
||||||
|
});
|
||||||
|
|
||||||
// Add types reference
|
// Add types reference
|
||||||
addTemplate({
|
addTemplate({
|
||||||
filename: 'docs-metadata-types.d.ts',
|
filename: 'docs-metadata-types.d.ts',
|
||||||
@@ -89,6 +100,11 @@ declare module '#docs/metadata' {
|
|||||||
for (const pkg of metadata.packages) {
|
for (const pkg of metadata.packages) {
|
||||||
routes.add(`/${pkg.slug}`);
|
routes.add(`/${pkg.slug}`);
|
||||||
|
|
||||||
|
// Hand-authored doc sections (any kind). The intro renders on the
|
||||||
|
// package landing, so only non-intro sections get their own route.
|
||||||
|
for (const section of pkg.docs)
|
||||||
|
if (!section.isIntro) routes.add(`/${pkg.slug}/${section.slug}`);
|
||||||
|
|
||||||
if (pkg.kind === 'api') {
|
if (pkg.kind === 'api') {
|
||||||
for (const category of pkg.categories)
|
for (const category of pkg.categories)
|
||||||
for (const item of category.items)
|
for (const item of category.items)
|
||||||
@@ -113,6 +129,14 @@ declare module '#docs/metadata' {
|
|||||||
write: true,
|
write: true,
|
||||||
getContents: () => {
|
getContents: () => {
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
|
// An item re-exported from several entry points yields the same key more
|
||||||
|
// than once; dedupe so the generated object literal has no duplicate keys.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const add = (key: string, demoPath: string) => {
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
entries.push(` '${key}': defineAsyncComponent(() => import('${demoPath}')),`);
|
||||||
|
};
|
||||||
|
|
||||||
for (const pkg of metadata.packages) {
|
for (const pkg of metadata.packages) {
|
||||||
// api items
|
// api items
|
||||||
@@ -120,7 +144,7 @@ declare module '#docs/metadata' {
|
|||||||
for (const item of cat.items) {
|
for (const item of cat.items) {
|
||||||
if (item.hasDemo) {
|
if (item.hasDemo) {
|
||||||
const demoPath = resolve(ROOT, dirname(item.sourcePath), 'demo.vue');
|
const demoPath = resolve(ROOT, dirname(item.sourcePath), 'demo.vue');
|
||||||
entries.push(` '${pkg.slug}/${item.slug}': defineAsyncComponent(() => import('${demoPath}')),`);
|
add(`${pkg.slug}/${item.slug}`, demoPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +152,7 @@ declare module '#docs/metadata' {
|
|||||||
for (const component of pkg.components) {
|
for (const component of pkg.components) {
|
||||||
if (component.hasDemo) {
|
if (component.hasDemo) {
|
||||||
const demoPath = resolve(ROOT, component.sourcePath, 'demo.vue');
|
const demoPath = resolve(ROOT, component.sourcePath, 'demo.vue');
|
||||||
entries.push(` '${pkg.slug}/${component.slug}': defineAsyncComponent(() => import('${demoPath}')),`);
|
add(`${pkg.slug}/${component.slug}`, demoPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +183,48 @@ import type { Component } from 'vue';
|
|||||||
declare module '#docs/demos' {
|
declare module '#docs/demos' {
|
||||||
export const demos: Record<string, Component>;
|
export const demos: Record<string, Component>;
|
||||||
}
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate hand-authored doc-section import map (`<pkg>/docs/*.vue`)
|
||||||
|
addTemplate({
|
||||||
|
filename: 'docs-sections.ts',
|
||||||
|
write: true,
|
||||||
|
getContents: () => {
|
||||||
|
const entries: string[] = [];
|
||||||
|
for (const pkg of metadata.packages) {
|
||||||
|
for (const section of pkg.docs) {
|
||||||
|
const sectionPath = resolve(ROOT, section.sourcePath);
|
||||||
|
entries.push(` '${pkg.slug}/${section.slug}': defineAsyncComponent(() => import('${sectionPath}')),`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return `import type { Component } from 'vue';\nexport const sections: Record<string, Component> = {};\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`import { defineAsyncComponent } from 'vue';`,
|
||||||
|
`import type { Component } from 'vue';`,
|
||||||
|
``,
|
||||||
|
`export const sections: Record<string, Component> = {`,
|
||||||
|
...entries,
|
||||||
|
`};`,
|
||||||
|
``,
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
nuxt.options.alias['#docs/sections'] = resolve(nuxt.options.buildDir, 'docs-sections');
|
||||||
|
|
||||||
|
addTemplate({
|
||||||
|
filename: 'docs-sections-types.d.ts',
|
||||||
|
write: true,
|
||||||
|
getContents: () => `
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
declare module '#docs/sections' {
|
||||||
|
export const sections: Record<string, Component>;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export interface PackageMeta {
|
|||||||
// ── kind: 'guide' ──────────────────────────────────────────────────────────
|
// ── kind: 'guide' ──────────────────────────────────────────────────────────
|
||||||
/** Prose sections rendered from Markdown (kind === 'guide') */
|
/** Prose sections rendered from Markdown (kind === 'guide') */
|
||||||
sections: GuideSection[];
|
sections: GuideSection[];
|
||||||
|
|
||||||
|
// ── any kind ───────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Hand-authored `.vue` documentation pages discovered from `<pkg>/docs/*.vue`.
|
||||||
|
* Independent of `kind` — an `api` package can still ship a rich intro and
|
||||||
|
* several prose sections alongside its auto-generated reference.
|
||||||
|
*/
|
||||||
|
docs: DocSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API kind ─────────────────────────────────────────────────────────────────
|
// ── API kind ─────────────────────────────────────────────────────────────────
|
||||||
@@ -152,6 +160,21 @@ export interface GuideSection {
|
|||||||
markdown: string;
|
markdown: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hand-authored .vue doc sections (any kind) ──────────────────────────────────
|
||||||
|
|
||||||
|
export interface DocSection {
|
||||||
|
/** Display title (from a `<!-- title: … -->` comment or the filename). */
|
||||||
|
title: string;
|
||||||
|
/** URL-friendly slug, e.g. "introduction" or "concepts". */
|
||||||
|
slug: string;
|
||||||
|
/** Sort order (from a `<!-- order: N -->` comment or a numeric filename prefix). */
|
||||||
|
order: number;
|
||||||
|
/** `true` for `docs/intro.vue` — rendered as the package landing page. */
|
||||||
|
isIntro: boolean;
|
||||||
|
/** Relative path to the `.vue` file from repo root (for the GitHub source link). */
|
||||||
|
sourcePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared leaf types ──────────────────────────────────────────────────────────
|
// ── Shared leaf types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ParamMeta {
|
export interface ParamMeta {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Docs MCP server
|
||||||
|
|
||||||
|
An [MCP](https://modelcontextprotocol.io) server that exposes the `@robonen/tools`
|
||||||
|
documentation to any MCP client (Claude Code, Claude Desktop, Cursor, …).
|
||||||
|
|
||||||
|
It is **served by the Nuxt/Nitro docs server itself** — there is no separate
|
||||||
|
process. The documentation metadata is the same data that renders the docs site
|
||||||
|
(produced by [`../extractor`](../extractor) at build time and injected into Nitro
|
||||||
|
as the `#docs/server-metadata` virtual), so what an agent reads is always in sync
|
||||||
|
with the site.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Start the docs server, and the MCP endpoint comes up with it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm docs:dev # → http://localhost:3000, MCP at http://localhost:3000/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /mcp` speaks the MCP **Streamable HTTP** transport (stateless, JSON
|
||||||
|
responses). The route lives at [`../../server/routes/mcp.post.ts`](../../server/routes/mcp.post.ts).
|
||||||
|
|
||||||
|
## Register with a client
|
||||||
|
|
||||||
|
A project-scoped [`.mcp.json`](../../../.mcp.json) at the repo root already points
|
||||||
|
Claude Code at the endpoint — start the docs server, then approve the
|
||||||
|
`robonen-docs` server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"robonen-docs": { "type": "http", "url": "http://localhost:3000/mcp" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Arguments | Returns |
|
||||||
|
| --------------- | ------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `list_packages` | — | Every documented package grouped by core / vue / configs / infra. |
|
||||||
|
| `search_docs` | `query`, `limit?` | Ranked `package/slug` matches across items, components and guides. |
|
||||||
|
| `get_package` | `slug` | A package's table of contents (categories, components or sections). |
|
||||||
|
| `get_doc` | `package`, `name` | Full reference for one item: signatures, params, examples, props/emits. |
|
||||||
|
|
||||||
|
`name` accepts either the URL slug (`use-clipboard`) or the exported name
|
||||||
|
(`useClipboard`). Slugs are unique within a package; case-only collisions (e.g.
|
||||||
|
the `useProjection` function vs the `UseProjection` type) are disambiguated with
|
||||||
|
a kind suffix (`use-projection-type`).
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- `robonen-docs://index` — the documentation index.
|
||||||
|
- `robonen-docs://{package}/{slug}` — full Markdown for a single documented item
|
||||||
|
(listable, one entry per leaf).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| ------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `../../server/routes/mcp.post.ts` | Nitro HTTP route — bridges the request to the MCP transport. |
|
||||||
|
| `create-server.ts` | Builds the configured `McpServer` (tools + resources) from metadata. |
|
||||||
|
| `docs-index.ts` | Pure query layer — flatten, unique-slug, search, resolve. |
|
||||||
|
| `format.ts` | Markdown renderers for tool/resource payloads. |
|
||||||
|
| `*.test.ts` | Unit tests for the query layer. |
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Builds a configured MCP server exposing the @robonen/tools documentation.
|
||||||
|
*
|
||||||
|
* Transport-agnostic: the same server is mounted on the Nuxt/Nitro HTTP route
|
||||||
|
* (see `docs/server/routes/mcp.post.ts`). Given already-extracted
|
||||||
|
* {@link DocsMetadata}, it registers the docs tools and resources.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { DocsMetadata } from '../extractor/types';
|
||||||
|
import type { Leaf } from './docs-index';
|
||||||
|
import { buildLeaves, getPackage, resolveEntry, search } from './docs-index';
|
||||||
|
import {
|
||||||
|
renderDocEntry,
|
||||||
|
renderPackageList,
|
||||||
|
renderPackageOverview,
|
||||||
|
renderSearchResults,
|
||||||
|
} from './format';
|
||||||
|
|
||||||
|
const INSTRUCTIONS = [
|
||||||
|
'Documentation for the @robonen/tools monorepo (core utilities, Vue composables &',
|
||||||
|
'primitives, shared configs). Workflow: call `list_packages` to see what exists,',
|
||||||
|
'`search_docs` to find an item by keyword, `get_package` for a package\'s table of',
|
||||||
|
'contents, and `get_doc` for the full reference (signatures, params, examples,',
|
||||||
|
'props/emits, demo source) of one item. Slugs are kebab-case; names are as exported.',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
/** Wrap a not-found message as a non-fatal tool error. */
|
||||||
|
function toolError(message: string) {
|
||||||
|
return { content: [{ type: 'text' as const, text: message }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap rendered Markdown as a successful tool result. */
|
||||||
|
function toolText(text: string) {
|
||||||
|
return { content: [{ type: 'text' as const, text }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerTools(server: McpServer, metadata: DocsMetadata, leaves: Leaf[]): void {
|
||||||
|
server.registerTool(
|
||||||
|
'list_packages',
|
||||||
|
{
|
||||||
|
title: 'List documentation packages',
|
||||||
|
description:
|
||||||
|
'List every documented @robonen/tools package (core, vue, configs, infra) with its kind, '
|
||||||
|
+ 'version, description and entry count. Start here to discover what is available.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => toolText(renderPackageList(metadata)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'search_docs',
|
||||||
|
{
|
||||||
|
title: 'Search documentation',
|
||||||
|
description:
|
||||||
|
'Full-text search across all documented functions, classes, types, components and guide '
|
||||||
|
+ 'sections. Returns ranked matches as `package/slug` references to pass to get_doc.',
|
||||||
|
inputSchema: {
|
||||||
|
query: z.string().min(1).describe('Search terms, e.g. "debounce", "clipboard", "eslint imports".'),
|
||||||
|
limit: z.number().int().min(1).max(50).optional().describe('Maximum number of results (default 20).'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ query, limit }) => toolText(renderSearchResults(search(leaves, query, limit ?? 20), query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_package',
|
||||||
|
{
|
||||||
|
title: 'Get a package overview',
|
||||||
|
description:
|
||||||
|
'Show a package\'s full table of contents: categories and items (api), components and their '
|
||||||
|
+ 'parts (components), or sections (guide). Pass the package slug from list_packages.',
|
||||||
|
inputSchema: {
|
||||||
|
slug: z.string().min(1).describe('Package slug, e.g. "stdlib", "toolkit", "primitives".'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
const pkg = getPackage(metadata, slug);
|
||||||
|
if (!pkg) return toolError(`No package with slug "${slug}". Call list_packages to see available slugs.`);
|
||||||
|
return toolText(renderPackageOverview(pkg));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_doc',
|
||||||
|
{
|
||||||
|
title: 'Get full documentation for an item',
|
||||||
|
description:
|
||||||
|
'Return the complete documentation for a single function, class, type, component or guide '
|
||||||
|
+ 'section: signatures, parameters, return type, examples, props/emits, demo source and the '
|
||||||
|
+ 'source path. Pass the package slug plus the item slug or its exported name.',
|
||||||
|
inputSchema: {
|
||||||
|
package: z.string().min(1).describe('Package slug, e.g. "stdlib".'),
|
||||||
|
name: z.string().min(1).describe('Item slug or exported name, e.g. "clamp" or "useClipboard".'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ package: pkgSlug, name }) => {
|
||||||
|
const entry = resolveEntry(leaves, pkgSlug, name);
|
||||||
|
if (!entry) {
|
||||||
|
return toolError(
|
||||||
|
`No documented item "${name}" in package "${pkgSlug}". Call get_package("${pkgSlug}") to list its items.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return toolText(renderDocEntry(entry));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerResources(server: McpServer, metadata: DocsMetadata, leaves: Leaf[]): void {
|
||||||
|
// The whole table of contents as a single browsable resource.
|
||||||
|
server.registerResource(
|
||||||
|
'docs-index',
|
||||||
|
'robonen-docs://index',
|
||||||
|
{
|
||||||
|
title: '@robonen/tools documentation index',
|
||||||
|
description: 'Table of contents for every documented package.',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
},
|
||||||
|
async uri => ({
|
||||||
|
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderPackageList(metadata) }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One resource per documented leaf, addressable as robonen-docs://<package>/<slug>.
|
||||||
|
server.registerResource(
|
||||||
|
'docs-entry',
|
||||||
|
new ResourceTemplate('robonen-docs://{package}/{slug}', {
|
||||||
|
list: async () => ({
|
||||||
|
resources: leaves.map(leaf => ({
|
||||||
|
uri: `robonen-docs://${leaf.packageSlug}/${leaf.slug}`,
|
||||||
|
name: `${leaf.packageName} / ${leaf.name}`,
|
||||||
|
description: leaf.description || `${leaf.badge} in ${leaf.packageName}`,
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
title: 'Documentation entry',
|
||||||
|
description: 'Full documentation for a single documented item.',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
},
|
||||||
|
async (uri, variables) => {
|
||||||
|
const pkgSlug = String(variables.package);
|
||||||
|
const slug = String(variables.slug);
|
||||||
|
const entry = resolveEntry(leaves, pkgSlug, slug);
|
||||||
|
if (!entry) throw new Error(`Unknown documentation resource: ${uri.href}`);
|
||||||
|
return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderDocEntry(entry) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an MCP server for the given documentation metadata, ready to connect to a transport. */
|
||||||
|
export function createDocsMcpServer(metadata: DocsMetadata, version = '0.0.0'): McpServer {
|
||||||
|
const leaves = buildLeaves(metadata);
|
||||||
|
const server = new McpServer({ name: 'robonen-docs', version }, { instructions: INSTRUCTIONS });
|
||||||
|
registerTools(server, metadata, leaves);
|
||||||
|
registerResources(server, metadata, leaves);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { DocsMetadata, ItemMeta } from '../extractor/types';
|
||||||
|
import { buildLeaves, countEntries, getPackage, groupPackages, resolveEntry, search } from './docs-index';
|
||||||
|
|
||||||
|
function item(partial: Partial<ItemMeta> & Pick<ItemMeta, 'name' | 'slug' | 'kind' | 'description'>): ItemMeta {
|
||||||
|
return {
|
||||||
|
since: '',
|
||||||
|
signatures: [],
|
||||||
|
params: [],
|
||||||
|
returns: null,
|
||||||
|
typeParams: [],
|
||||||
|
examples: [],
|
||||||
|
methods: [],
|
||||||
|
properties: [],
|
||||||
|
hasDemo: false,
|
||||||
|
demoSource: '',
|
||||||
|
hasTests: false,
|
||||||
|
relatedTypes: [],
|
||||||
|
sourcePath: '',
|
||||||
|
entryPoint: '.',
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: DocsMetadata = {
|
||||||
|
generatedAt: '2026-06-08T00:00:00.000Z',
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: '@robonen/stdlib',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Standard library utilities',
|
||||||
|
slug: 'stdlib',
|
||||||
|
kind: 'api',
|
||||||
|
group: 'core',
|
||||||
|
entryPoints: ['.'],
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
name: 'Numbers',
|
||||||
|
slug: 'numbers',
|
||||||
|
items: [
|
||||||
|
item({ name: 'clamp', slug: 'clamp', kind: 'function', description: 'Clamp a number to a range' }),
|
||||||
|
item({ name: 'debounce', slug: 'debounce', kind: 'function', description: 'Debounce a function call' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '@robonen/toolkit',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'Vue composables',
|
||||||
|
slug: 'toolkit',
|
||||||
|
kind: 'components',
|
||||||
|
group: 'vue',
|
||||||
|
entryPoints: ['.'],
|
||||||
|
categories: [],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'useClipboard',
|
||||||
|
slug: 'use-clipboard',
|
||||||
|
description: 'Reactive clipboard access',
|
||||||
|
entryPoint: './use-clipboard',
|
||||||
|
parts: [],
|
||||||
|
hasDemo: false,
|
||||||
|
demoSource: '',
|
||||||
|
sourcePath: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '@robonen/eslint',
|
||||||
|
version: '3.0.0',
|
||||||
|
description: 'Shared ESLint config',
|
||||||
|
slug: 'eslint',
|
||||||
|
kind: 'guide',
|
||||||
|
group: 'configs',
|
||||||
|
entryPoints: ['.'],
|
||||||
|
categories: [],
|
||||||
|
components: [],
|
||||||
|
sections: [
|
||||||
|
{ title: 'Imports preset', slug: 'imports', markdown: '# Imports\nSorts and dedupes imports.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildLeaves', () => {
|
||||||
|
it('flattens every leaf across all package kinds', () => {
|
||||||
|
const leaves = buildLeaves(metadata);
|
||||||
|
expect(leaves).toHaveLength(4);
|
||||||
|
expect(leaves.map(l => l.slug).sort()).toEqual(['clamp', 'debounce', 'imports', 'use-clipboard']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags each leaf with the right badge', () => {
|
||||||
|
const byName = new Map(buildLeaves(metadata).map(l => [l.name, l.badge]));
|
||||||
|
expect(byName.get('clamp')).toBe('function');
|
||||||
|
expect(byName.get('useClipboard')).toBe('component');
|
||||||
|
expect(byName.get('Imports preset')).toBe('guide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search', () => {
|
||||||
|
const leaves = buildLeaves(metadata);
|
||||||
|
|
||||||
|
it('returns empty for a blank query', () => {
|
||||||
|
expect(search(leaves, ' ')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks an exact name match first', () => {
|
||||||
|
const hits = search(leaves, 'clamp');
|
||||||
|
expect(hits[0]?.name).toBe('clamp');
|
||||||
|
expect(hits[0]?.packageSlug).toBe('stdlib');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches on description body, not just names', () => {
|
||||||
|
const hits = search(leaves, 'clipboard');
|
||||||
|
expect(hits.map(h => h.name)).toContain('useClipboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies AND semantics across tokens', () => {
|
||||||
|
expect(search(leaves, 'clamp clipboard')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the limit', () => {
|
||||||
|
expect(search(leaves, 'a', 1)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPackage / resolveEntry', () => {
|
||||||
|
const leaves = buildLeaves(metadata);
|
||||||
|
|
||||||
|
it('finds a package by slug, case-insensitively', () => {
|
||||||
|
expect(getPackage(metadata, 'STDLIB')?.name).toBe('@robonen/stdlib');
|
||||||
|
expect(getPackage(metadata, 'nope')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves an api item by slug or exported name', () => {
|
||||||
|
expect(resolveEntry(leaves, 'stdlib', 'clamp')?.kind).toBe('api');
|
||||||
|
expect(resolveEntry(leaves, 'stdlib', 'Clamp')?.kind).toBe('api');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a component by slug and by name', () => {
|
||||||
|
expect(resolveEntry(leaves, 'toolkit', 'use-clipboard')?.kind).toBe('components');
|
||||||
|
expect(resolveEntry(leaves, 'toolkit', 'useClipboard')?.kind).toBe('components');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a guide section', () => {
|
||||||
|
const entry = resolveEntry(leaves, 'eslint', 'imports');
|
||||||
|
expect(entry?.kind).toBe('guide');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an unknown item', () => {
|
||||||
|
expect(resolveEntry(leaves, 'stdlib', 'missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slug uniqueness & collisions', () => {
|
||||||
|
// A function and a co-located type/interface whose names differ only in case
|
||||||
|
// both slugify to the same value — the real extractor produces these in
|
||||||
|
// @robonen/editor and @robonen/vue.
|
||||||
|
const colliding: DocsMetadata = {
|
||||||
|
generatedAt: '2026-06-08T00:00:00.000Z',
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
name: '@robonen/editor',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Editor',
|
||||||
|
slug: 'editor',
|
||||||
|
kind: 'api',
|
||||||
|
group: 'vue',
|
||||||
|
entryPoints: ['.'],
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
name: 'Model',
|
||||||
|
slug: 'model',
|
||||||
|
items: [
|
||||||
|
item({ name: 'position', slug: 'position', kind: 'function', description: 'Create a position' }),
|
||||||
|
item({ name: 'Position', slug: 'position', kind: 'interface', description: 'A position' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('disambiguates colliding slugs so every (package, slug) pair is unique', () => {
|
||||||
|
const leaves = buildLeaves(colliding);
|
||||||
|
const slugs = leaves.map(l => l.slug);
|
||||||
|
expect(slugs).toEqual(['position', 'position-interface']);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches both colliding symbols — function and interface — independently', () => {
|
||||||
|
const leaves = buildLeaves(colliding);
|
||||||
|
// Exact case-sensitive name disambiguates the function from the interface.
|
||||||
|
const fn = resolveEntry(leaves, 'editor', 'position');
|
||||||
|
const iface = resolveEntry(leaves, 'editor', 'Position');
|
||||||
|
expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
|
||||||
|
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
|
||||||
|
// The disambiguated slug also resolves the interface directly.
|
||||||
|
const bySlug = resolveEntry(leaves, 'editor', 'position-interface');
|
||||||
|
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a slug contains a URI-reserved character', () => {
|
||||||
|
const bad: DocsMetadata = {
|
||||||
|
generatedAt: '2026-06-08T00:00:00.000Z',
|
||||||
|
packages: [{
|
||||||
|
name: '@robonen/x', version: '1.0.0', description: '', slug: 'x', kind: 'guide', group: 'infra',
|
||||||
|
entryPoints: ['.'], categories: [], components: [],
|
||||||
|
sections: [{ title: 'Nested', slug: 'rules/no-foo', markdown: '#' }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
expect(() => buildLeaves(bad)).toThrow(/reserved URI character/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('grouping helpers', () => {
|
||||||
|
it('orders groups core → vue → configs and drops empties', () => {
|
||||||
|
expect(groupPackages(metadata).map(g => g.group)).toEqual(['core', 'vue', 'configs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts entries per package kind', () => {
|
||||||
|
expect(countEntries(metadata.packages[0]!)).toBe(2);
|
||||||
|
expect(countEntries(metadata.packages[1]!)).toBe(1);
|
||||||
|
expect(countEntries(metadata.packages[2]!)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Markdown renderers turning structured {@link DocsMetadata} into the text
|
||||||
|
* payloads returned by the MCP tools/resources. Output targets an LLM reader:
|
||||||
|
* compact, signature-first, code-fenced where it helps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ComponentMeta,
|
||||||
|
ComponentPartMeta,
|
||||||
|
DocsMetadata,
|
||||||
|
GuideSection,
|
||||||
|
ItemMeta,
|
||||||
|
MethodMeta,
|
||||||
|
PackageMeta,
|
||||||
|
ParamMeta,
|
||||||
|
PropertyMeta,
|
||||||
|
} from '../extractor/types';
|
||||||
|
import type { DocEntry, SearchHit } from './docs-index';
|
||||||
|
import { countEntries, groupPackages } from './docs-index';
|
||||||
|
|
||||||
|
/** Collapse whitespace and trim — keeps table cells on one line. */
|
||||||
|
function inline(text: string): string {
|
||||||
|
return text.replaceAll(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape pipe / newline so a value is safe inside a Markdown table cell. */
|
||||||
|
function cell(text: string): string {
|
||||||
|
const value = inline(text).replaceAll('|', '\\|');
|
||||||
|
return value.length > 0 ? value : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fenced code block; language defaults to `ts`. */
|
||||||
|
function fence(code: string, lang = 'ts'): string {
|
||||||
|
return `\`\`\`${lang}\n${code.trim()}\n\`\`\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum demo lines embedded verbatim before we truncate and link the source. */
|
||||||
|
const MAX_DEMO_LINES = 140;
|
||||||
|
|
||||||
|
/** A `## Demo` block, capped so a large demo.vue cannot bloat a single tool result. */
|
||||||
|
function demoBlock(source: string, sourcePath: string): string[] {
|
||||||
|
const lines = source.trim().split('\n');
|
||||||
|
if (lines.length <= MAX_DEMO_LINES) return ['## Demo', '', fence(source, 'vue'), ''];
|
||||||
|
return [
|
||||||
|
'## Demo',
|
||||||
|
'',
|
||||||
|
fence(lines.slice(0, MAX_DEMO_LINES).join('\n'), 'vue'),
|
||||||
|
`_Demo truncated to ${MAX_DEMO_LINES} of ${lines.length} lines — full source: \`${sourcePath}\`._`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a GitHub-flavoured Markdown table from a header + rows. */
|
||||||
|
function table(header: string[], rows: string[][]): string {
|
||||||
|
const head = `| ${header.join(' | ')} |`;
|
||||||
|
const divider = `| ${header.map(() => '---').join(' | ')} |`;
|
||||||
|
const body = rows.map(r => `| ${r.join(' | ')} |`).join('\n');
|
||||||
|
return [head, divider, body].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A required/optional name with its default, formatted for a table cell. */
|
||||||
|
function paramName(p: ParamMeta | PropertyMeta): string {
|
||||||
|
const base = p.optional ? `${p.name}?` : p.name;
|
||||||
|
return p.defaultValue ? `${base} = ${p.defaultValue}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Package list (table of contents) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderPackageList(metadata: DocsMetadata): string {
|
||||||
|
const lines: string[] = ['# @robonen/tools — documentation index', ''];
|
||||||
|
|
||||||
|
for (const { label, packages } of groupPackages(metadata)) {
|
||||||
|
lines.push(`## ${label}`, '');
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const count = countEntries(pkg);
|
||||||
|
const noun = pkg.kind === 'api' ? 'items' : pkg.kind === 'components' ? 'components' : 'sections';
|
||||||
|
lines.push(
|
||||||
|
`- **${pkg.slug}** — \`${pkg.name}\`@${pkg.version} · _${pkg.kind}_ · ${count} ${noun}${
|
||||||
|
pkg.description ? `\n ${inline(pkg.description)}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'---',
|
||||||
|
`${metadata.packages.length} packages · generated ${metadata.generatedAt}`,
|
||||||
|
'Use `get_package(slug)` for a package\'s contents, then `get_doc(package, name)` for full detail.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Package overview ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderPackageOverview(pkg: PackageMeta): string {
|
||||||
|
const lines: string[] = [`# ${pkg.name}@${pkg.version}`, ''];
|
||||||
|
if (pkg.description) lines.push(inline(pkg.description), '');
|
||||||
|
lines.push(`_kind: ${pkg.kind} · group: ${pkg.group} · entry points: ${pkg.entryPoints.join(', ')}_`, '');
|
||||||
|
|
||||||
|
if (pkg.kind === 'api') {
|
||||||
|
for (const category of pkg.categories) {
|
||||||
|
lines.push(`## ${category.name}`, '');
|
||||||
|
for (const item of category.items) {
|
||||||
|
lines.push(`- \`${item.name}\` · _${item.kind}_${item.description ? ` — ${inline(item.description)}` : ''}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (pkg.kind === 'components') {
|
||||||
|
lines.push('## Components', '');
|
||||||
|
for (const c of pkg.components) {
|
||||||
|
const parts = c.parts.map(p => p.name).join(', ');
|
||||||
|
lines.push(`- **${c.name}** (\`${c.slug}\`)${c.description ? ` — ${inline(c.description)}` : ''}`);
|
||||||
|
if (parts) lines.push(` parts: ${parts}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push('## Sections', '');
|
||||||
|
for (const s of pkg.sections) lines.push(`- **${s.title}** (\`${s.slug}\`)`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('---', `Use \`get_doc("${pkg.slug}", name)\` for the full documentation of an item.`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single entry ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderParams(params: ParamMeta[]): string[] {
|
||||||
|
if (params.length === 0) return [];
|
||||||
|
const rows = params.map(p => [cell(paramName(p)), cell(`\`${p.type}\``), cell(p.description)]);
|
||||||
|
return ['## Parameters', '', table(['Parameter', 'Type', 'Description'], rows), ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProperties(title: string, props: PropertyMeta[]): string[] {
|
||||||
|
if (props.length === 0) return [];
|
||||||
|
const rows = props.map(p => [
|
||||||
|
cell(`${paramName(p)}${p.readonly ? ' _(readonly)_' : ''}`),
|
||||||
|
cell(`\`${p.type}\``),
|
||||||
|
cell(p.description),
|
||||||
|
]);
|
||||||
|
return [`## ${title}`, '', table(['Name', 'Type', 'Description'], rows), ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMethods(methods: MethodMeta[]): string[] {
|
||||||
|
if (methods.length === 0) return [];
|
||||||
|
const out: string[] = ['## Methods', ''];
|
||||||
|
for (const m of methods) {
|
||||||
|
out.push(`### ${m.name}${m.visibility && m.visibility !== 'public' ? ` _(${m.visibility})_` : ''}`);
|
||||||
|
if (m.description) out.push('', inline(m.description));
|
||||||
|
if (m.signatures.length > 0) out.push('', fence(m.signatures.join('\n')));
|
||||||
|
out.push(...renderParams(m.params));
|
||||||
|
if (m.returns) out.push(`**Returns** \`${inline(m.returns.type)}\`${m.returns.description ? ` — ${inline(m.returns.description)}` : ''}`, '');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApiItem(pkg: PackageMeta, item: ItemMeta): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
`# ${item.name}`,
|
||||||
|
'',
|
||||||
|
`_${item.kind} · ${pkg.name}${item.since ? ` · since ${item.since}` : ''} · \`${item.entryPoint}\`_`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (item.description) lines.push(inline(item.description), '');
|
||||||
|
|
||||||
|
if (item.signatures.length > 0) {
|
||||||
|
lines.push('## Signature', '', fence(item.signatures.join('\n\n')), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.typeParams.length > 0) {
|
||||||
|
const rows = item.typeParams.map(tp => [
|
||||||
|
cell(tp.name),
|
||||||
|
cell(tp.constraint ? `\`${tp.constraint}\`` : '—'),
|
||||||
|
cell(tp.default ? `\`${tp.default}\`` : '—'),
|
||||||
|
cell(tp.description),
|
||||||
|
]);
|
||||||
|
lines.push('## Type Parameters', '', table(['Name', 'Constraint', 'Default', 'Description'], rows), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(...renderParams(item.params));
|
||||||
|
|
||||||
|
if (item.returns) {
|
||||||
|
lines.push(
|
||||||
|
'## Returns',
|
||||||
|
'',
|
||||||
|
`\`${inline(item.returns.type)}\`${item.returns.description ? ` — ${inline(item.returns.description)}` : ''}`,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(...renderMethods(item.methods));
|
||||||
|
lines.push(...renderProperties('Properties', item.properties));
|
||||||
|
|
||||||
|
if (item.examples.length > 0) {
|
||||||
|
lines.push('## Examples', '');
|
||||||
|
for (const ex of item.examples) lines.push(fence(ex), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.relatedTypes.length > 0) {
|
||||||
|
lines.push('## Related Types', '');
|
||||||
|
for (const t of item.relatedTypes) {
|
||||||
|
lines.push(`### ${t.name}${t.description ? ` — ${inline(t.description)}` : ''}`);
|
||||||
|
if (t.signatures.length > 0) lines.push('', fence(t.signatures.join('\n')));
|
||||||
|
lines.push(...renderProperties('Properties', t.properties));
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.hasDemo && item.demoSource) {
|
||||||
|
lines.push(...demoBlock(item.demoSource, item.sourcePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('---', `Source: \`${item.sourcePath}\`${item.hasTests ? ' · has tests' : ''}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponentPart(part: ComponentPartMeta): string[] {
|
||||||
|
const out: string[] = [`### ${part.name}${part.role ? ` _(${part.role})_` : ''}`];
|
||||||
|
if (part.description) out.push('', inline(part.description));
|
||||||
|
out.push(...renderProperties('Props', part.props));
|
||||||
|
|
||||||
|
if (part.emits.length > 0) {
|
||||||
|
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
|
||||||
|
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent(pkg: PackageMeta, component: ComponentMeta): string {
|
||||||
|
const lines: string[] = [`# ${component.name}`, '', `_component · ${pkg.name} · \`${component.entryPoint}\`_`, ''];
|
||||||
|
if (component.description) lines.push(inline(component.description), '');
|
||||||
|
|
||||||
|
lines.push('## Anatomy', '');
|
||||||
|
for (const part of component.parts) lines.push(...renderComponentPart(part));
|
||||||
|
|
||||||
|
if (component.hasDemo && component.demoSource) {
|
||||||
|
lines.push(...demoBlock(component.demoSource, component.sourcePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('---', `Source: \`${component.sourcePath}\``);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGuide(pkg: PackageMeta, section: GuideSection): string {
|
||||||
|
return [`# ${section.title}`, '', `_guide · ${pkg.name}_`, '', section.markdown.trim()].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render any documented leaf to full Markdown. */
|
||||||
|
export function renderDocEntry(entry: DocEntry): string {
|
||||||
|
if (entry.kind === 'api') return renderApiItem(entry.pkg, entry.item);
|
||||||
|
if (entry.kind === 'components') return renderComponent(entry.pkg, entry.component);
|
||||||
|
return renderGuide(entry.pkg, entry.section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search results ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderSearchResults(hits: SearchHit[], query: string): string {
|
||||||
|
if (hits.length === 0) {
|
||||||
|
return `No documentation matches "${query}". Try a broader term, or call list_packages to browse.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [`Found ${hits.length} result${hits.length === 1 ? '' : 's'} for "${query}":`, ''];
|
||||||
|
for (const hit of hits) {
|
||||||
|
lines.push(
|
||||||
|
`- **${hit.name}** · _${hit.badge}_ · \`${hit.packageSlug}/${hit.slug}\`${
|
||||||
|
hit.description ? `\n ${inline(hit.description)}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('', 'Call `get_doc(package, name)` with the `package/slug` above for full detail.');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -20,8 +20,18 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
|
||||||
|
// Dev-only: the file-based payload cache collides on parent+child routes that
|
||||||
|
// share a segment (e.g. `/vue` is written as a file while `/vue/*` needs `vue`
|
||||||
|
// to be a directory → ENOTDIR). Production prerender writes each route to its
|
||||||
|
// own dir, so payload extraction is left enabled there.
|
||||||
|
$development: {
|
||||||
|
experimental: { payloadExtraction: false },
|
||||||
|
},
|
||||||
|
|
||||||
routeRules: {
|
routeRules: {
|
||||||
'/**': { prerender: true },
|
'/**': { prerender: true },
|
||||||
|
// The MCP endpoint is a dynamic POST handler — never prerender it.
|
||||||
|
'/mcp': { prerender: false },
|
||||||
},
|
},
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
|
|||||||
+3
-1
@@ -15,8 +15,10 @@
|
|||||||
"extract": "jiti ./modules/extractor/extract.ts"
|
"extract": "jiti ./modules/extractor/extract.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"shiki": "^4.0.1"
|
"shiki": "^4.0.1",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/fonts": "^0.14.0",
|
"@nuxt/fonts": "^0.14.0",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* MCP endpoint, served by the Nuxt/Nitro server itself — no separate process.
|
||||||
|
*
|
||||||
|
* Speaks the MCP Streamable HTTP transport in stateless mode (one fresh server
|
||||||
|
* per request, plain JSON responses), reusing the shared `createDocsMcpServer`
|
||||||
|
* factory and the build-time documentation metadata injected by the extractor
|
||||||
|
* module as the `#docs/server-metadata` virtual.
|
||||||
|
*
|
||||||
|
* POST /mcp → start the dev server (`pnpm docs:dev`) and point your MCP client
|
||||||
|
* at http://localhost:3000/mcp.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import metadata from '#docs/server-metadata';
|
||||||
|
import pkg from '../../package.json';
|
||||||
|
import { createDocsMcpServer } from '../../modules/mcp/create-server';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// h3 has already buffered the JSON body; hand it to the transport so it does
|
||||||
|
// not try to re-read the consumed request stream.
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const server = createDocsMcpServer(metadata, pkg.version);
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined, // stateless: no session tracking for read-only docs
|
||||||
|
enableJsonResponse: true, // return a single JSON response rather than an SSE stream
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tear both down once the response is flushed.
|
||||||
|
event.node.res.on('close', () => {
|
||||||
|
void transport.close();
|
||||||
|
void server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
// The transport writes the status, headers and body directly to res and ends
|
||||||
|
// it; h3 sees `res.writableEnded` and does not attempt a second response.
|
||||||
|
await transport.handleRequest(event.node.req, event.node.res, body);
|
||||||
|
});
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/** Types for the Nitro virtual module injected by `modules/extractor`. */
|
||||||
|
declare module '#docs/server-metadata' {
|
||||||
|
import type { DocsMetadata } from '../modules/extractor/types';
|
||||||
|
|
||||||
|
const metadata: DocsMetadata;
|
||||||
|
export default metadata;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// Only the build-time tooling (extractor / MCP server) is unit-tested here;
|
||||||
|
// the Nuxt app itself is covered elsewhere.
|
||||||
|
include: ['modules/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user