Render signature-level parameter docs and surface default exports in MCP bundles
This commit is contained in:
@@ -1,22 +1,58 @@
|
|||||||
import { ReflectionKind } from 'typedoc';
|
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.
|
* Convert a TypeDoc serialized comment into a Google-style markdown docstring.
|
||||||
*
|
*
|
||||||
* TypeDoc stores comments as a list of display parts (text/code/inline-tag)
|
* TypeDoc stores comments as a list of display parts (text/code/inline-tag)
|
||||||
* plus blockTags (e.g. @example, @returns, @param). This function renders
|
* plus blockTags (e.g. @example, @returns). It moves `@param`/`@typeParam`
|
||||||
* them back to a stable markdown string so both MkDocs and MCP consumers
|
* 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.
|
* receive the same human-readable documentation.
|
||||||
*/
|
*/
|
||||||
export function renderComment(comment: any): string {
|
export function renderComment(
|
||||||
if (!comment) return '';
|
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 lines: string[] = [];
|
||||||
|
|
||||||
const summary = renderParts(comment.summary);
|
const summary = renderParts(comment?.summary);
|
||||||
if (summary.trim()) lines.push(summary.trim());
|
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(/^@/, '');
|
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();
|
const content = renderParts(tag.content).trim();
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
const heading = tagHeading(tagName);
|
const heading = tagHeading(tagName);
|
||||||
@@ -29,6 +65,11 @@ export function renderComment(comment: any): string {
|
|||||||
return lines.join('\n').trim();
|
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 {
|
function tagHeading(tag: string): string {
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case 'param':
|
case 'param':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
resolveModulePath,
|
resolveModulePath,
|
||||||
collectModules
|
collectModules
|
||||||
} from '../models/typedoc.js';
|
} 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';
|
import { bestSignature } from './signature.js';
|
||||||
|
|
||||||
export interface McpBundle {
|
export interface McpBundle {
|
||||||
@@ -60,9 +60,15 @@ export class TypeDocJsonToMCP {
|
|||||||
|
|
||||||
private renderModule(mod: any, modPath: string): Record<string, unknown> {
|
private renderModule(mod: any, modPath: string): Record<string, unknown> {
|
||||||
const objects: Record<string, unknown> = {};
|
const objects: Record<string, unknown> = {};
|
||||||
for (const child of mod.children || []) {
|
const children = (mod.children || []).filter((c: any) => c.kind !== RefKind.Module);
|
||||||
if (child.kind === RefKind.Module) continue;
|
const siblingNames = new Set(children.map((c: any) => c.name));
|
||||||
objects[child.name] = this.renderObject(child, modPath);
|
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 {
|
return {
|
||||||
path: modPath,
|
path: modPath,
|
||||||
@@ -71,16 +77,17 @@ export class TypeDocJsonToMCP {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderObject(ref: any, parentPath: string): Record<string, unknown> {
|
private renderObject(ref: any, parentPath: string, nameOverride?: string): Record<string, unknown> {
|
||||||
const kindName = classifyReact(ref.name, mapKind(ref.kind));
|
const name = nameOverride ?? ref.name;
|
||||||
const path = `${parentPath}.${ref.name}`;
|
const kindName = classifyReact(name, mapKind(ref.kind));
|
||||||
|
const path = `${parentPath}.${name}`;
|
||||||
|
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
name: ref.name,
|
name,
|
||||||
kind: kindName,
|
kind: kindName,
|
||||||
path,
|
path,
|
||||||
signature: bestSignature(ref),
|
signature: bestSignature(ref),
|
||||||
docstring: renderComment(ref.comment)
|
docstring: this.renderDocstring(ref)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer an explicit @kind tag when present (mirrors doc-forge kinds).
|
// Prefer an explicit @kind tag when present (mirrors doc-forge kinds).
|
||||||
@@ -93,6 +100,37 @@ export class TypeDocJsonToMCP {
|
|||||||
return data;
|
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<string, unknown> {
|
private collectMembers(ref: any, parentPath: string): Record<string, unknown> {
|
||||||
const members: Record<string, unknown> = {};
|
const members: Record<string, unknown> = {};
|
||||||
for (const child of ref.children || []) {
|
for (const child of ref.children || []) {
|
||||||
@@ -111,3 +149,30 @@ function findBlockTag(comment: any, tag: string): string | undefined {
|
|||||||
}
|
}
|
||||||
return 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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user