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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user