diff --git a/src/renderers/docstring.ts b/src/renderers/docstring.ts index eccc19f..7c5ad63 100644 --- a/src/renderers/docstring.ts +++ b/src/renderers/docstring.ts @@ -1,22 +1,58 @@ import { ReflectionKind } from 'typedoc'; +/** A single documented parameter (name + rendered description). */ +export interface ParamDoc { + name: string; + description: string; +} + /** * Convert a TypeDoc serialized comment into a Google-style markdown docstring. * * TypeDoc stores comments as a list of display parts (text/code/inline-tag) - * plus blockTags (e.g. @example, @returns, @param). This function renders - * them back to a stable markdown string so both MkDocs and MCP consumers + * plus blockTags (e.g. @example, @returns). It moves `@param`/`@typeParam` + * descriptions onto the signature's parameters, so those are passed in + * separately as `params`/`typeParams` and rendered as grouped sections. + * + * The result is a stable markdown string so both MkDocs and MCP consumers * receive the same human-readable documentation. */ -export function renderComment(comment: any): string { - if (!comment) return ''; +export function renderComment( + comment: any, + params?: ParamDoc[], + typeParams?: ParamDoc[] +): string { + const documentedParams = (params || []).filter((p) => (p.description || '').trim()); + const documentedTypeParams = (typeParams || []).filter((p) => (p.description || '').trim()); + if (!comment && !documentedParams.length && !documentedTypeParams.length) return ''; + const lines: string[] = []; - const summary = renderParts(comment.summary); + const summary = renderParts(comment?.summary); if (summary.trim()) lines.push(summary.trim()); - for (const tag of comment.blockTags || []) { + if (documentedTypeParams.length) { + lines.push(''); + lines.push('**Type Parameters:**'); + lines.push(''); + for (const p of documentedTypeParams) { + lines.push(`- \`${p.name}\` — ${oneLine(p.description)}`); + } + } + + if (documentedParams.length) { + lines.push(''); + lines.push('**Parameters:**'); + lines.push(''); + for (const p of documentedParams) { + lines.push(`- \`${p.name}\` — ${oneLine(p.description)}`); + } + } + + for (const tag of comment?.blockTags || []) { const tagName = (tag.tag || '').replace(/^@/, ''); + // @param / @typeParam are rendered from signature parameters above. + if (tagName === 'param' || tagName === 'typeParam' || tagName === 'template') continue; const content = renderParts(tag.content).trim(); if (!content) continue; const heading = tagHeading(tagName); @@ -29,6 +65,11 @@ export function renderComment(comment: any): string { return lines.join('\n').trim(); } +/** Collapse a possibly multi-line description to a single line. */ +function oneLine(text: string): string { + return (text || '').trim().replace(/\s*\n\s*/g, ' '); +} + function tagHeading(tag: string): string { switch (tag) { case 'param': diff --git a/src/renderers/mcp-renderer.ts b/src/renderers/mcp-renderer.ts index e94a896..0bf3c57 100644 --- a/src/renderers/mcp-renderer.ts +++ b/src/renderers/mcp-renderer.ts @@ -4,7 +4,7 @@ import { resolveModulePath, collectModules } from '../models/typedoc.js'; -import { renderComment, mapKind, classifyReact } from './docstring.js'; +import { renderComment, renderParts, mapKind, classifyReact, ParamDoc } from './docstring.js'; import { bestSignature } from './signature.js'; export interface McpBundle { @@ -60,9 +60,15 @@ export class TypeDocJsonToMCP { private renderModule(mod: any, modPath: string): Record { const objects: Record = {}; - for (const child of mod.children || []) { - if (child.kind === RefKind.Module) continue; - objects[child.name] = this.renderObject(child, modPath); + const children = (mod.children || []).filter((c: any) => c.kind !== RefKind.Module); + const siblingNames = new Set(children.map((c: any) => c.name)); + const baseName = moduleBaseName(modPath); + for (const child of children) { + // TypeDoc names default exports "default"; surface them under the + // module's own name (a React component/module convention) unless a + // sibling already claims that name. + const name = child.name === 'default' && !siblingNames.has(baseName) ? baseName : child.name; + objects[name] = this.renderObject(child, modPath, name); } return { path: modPath, @@ -71,16 +77,17 @@ export class TypeDocJsonToMCP { }; } - private renderObject(ref: any, parentPath: string): Record { - const kindName = classifyReact(ref.name, mapKind(ref.kind)); - const path = `${parentPath}.${ref.name}`; + private renderObject(ref: any, parentPath: string, nameOverride?: string): Record { + const name = nameOverride ?? ref.name; + const kindName = classifyReact(name, mapKind(ref.kind)); + const path = `${parentPath}.${name}`; const data: Record = { - name: ref.name, + name, kind: kindName, path, signature: bestSignature(ref), - docstring: renderComment(ref.comment) + docstring: this.renderDocstring(ref) }; // Prefer an explicit @kind tag when present (mirrors doc-forge kinds). @@ -93,6 +100,37 @@ export class TypeDocJsonToMCP { return data; } + /** + * Render a reflection's docstring, sourcing the summary/blockTags from the + * signature when TypeDoc has moved them there (functions, method-like + * type aliases) and attaching parameter documentation. + */ + private renderDocstring(ref: any): string { + const sig = bestSignatureRef(ref); + const refComment = ref.comment; + const sigComment = sig?.comment; + + const params: ParamDoc[] = (sig?.parameters || []) + .map((p: any) => ({ name: p.name, description: renderParts(p.comment?.summary) })) + .filter((p: ParamDoc) => (p.description || '').trim().length > 0); + + const typeParams: ParamDoc[] = (sig?.typeParameters || []) + .map((p: any) => ({ name: p.name, description: renderParts(p.comment?.summary) })) + .filter((p: ParamDoc) => (p.description || '').trim().length > 0); + + const summary = + refComment?.summary && refComment.summary.length + ? refComment.summary + : sigComment?.summary; + + const blockTags = dedupeTags([ + ...(refComment?.blockTags || []), + ...(sigComment?.blockTags || []) + ]); + + return renderComment({ summary, blockTags }, params, typeParams); + } + private collectMembers(ref: any, parentPath: string): Record { const members: Record = {}; for (const child of ref.children || []) { @@ -111,3 +149,30 @@ function findBlockTag(comment: any, tag: string): string | undefined { } return undefined; } + +/** Last segment of a (dotted) module path, used to name default exports. */ +function moduleBaseName(modPath: string): string { + const parts = modPath.split('.'); + return parts[parts.length - 1] || modPath; +} + +/** Find the signature TypeDoc attached comment data to, if any. */ +function bestSignatureRef(ref: any): any { + if (ref?.signatures?.length) return ref.signatures[0]; + const decl = ref?.type?.declaration; + if (decl?.signatures?.length) return decl.signatures[0]; + return undefined; +} + +/** Remove duplicate block tags (same tag + same content) across sources. */ +function dedupeTags(tags: any[]): any[] { + const seen = new Set(); + const out: any[] = []; + for (const tag of tags) { + const key = `${tag?.tag || ''}|${(tag?.content || []).map((p: any) => p.text ?? '').join('')}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(tag); + } + return out; +}