Add doc-weaver: TypeScript documentation compiler
TypeDoc + JSDoc (RDFC) compiler mirroring doc-forge. Emits a doc-forge-shaped MCP JSON bundle and a TypeDoc human site, with an MCP server (official SDK, stdio), a build/serve CLI, and React-aware kind classification. Includes the RDFC wiki (docs/wiki) and MkDocs config.
This commit is contained in:
138
src/cli/commands.ts
Normal file
138
src/cli/commands.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Command } from 'commander';
|
||||
import * as path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { runTypeDocJson } from '../loaders/typedoc-loader.js';
|
||||
import { generateHumanDocs } from '../renderers/human-docs.js';
|
||||
import { generateMcpResources, serveMcp } from './mcp-utils.js';
|
||||
|
||||
/**
|
||||
* Build documentation.
|
||||
*
|
||||
* Mirrors doc-forge's `build` command:
|
||||
* doc-weaver build --mcp --docs --module <entry>
|
||||
*/
|
||||
export function buildCommand(): Command {
|
||||
return new Command('build')
|
||||
.description('Build documentation (MCP JSON and/or a TypeDoc human site)')
|
||||
.option('--mcp', 'Generate MCP JSON resources (index/nav/modules)')
|
||||
.option('--docs', 'Generate the human-facing TypeDoc site')
|
||||
.option('--mkdocs', 'Alias for --docs (MkDocs parallel)')
|
||||
.option('--lib', 'Alias for --docs (library site)')
|
||||
.option('--api', 'API docs from an OpenAPI spec (reserved)')
|
||||
.option('--wiki', 'Hand-written wiki build (reserved)')
|
||||
.option('--module <path>', 'Entry point (file or directory) for TypeDoc')
|
||||
.option('--entry <file>', 'Entry file (alternative to --module)')
|
||||
.option('--project-name <name>', 'Project name override')
|
||||
.option('--name <name>', 'Site name (alias of --project-name)')
|
||||
.option('--tsconfig <file>', 'tsconfig.json path')
|
||||
.option('--out-dir <dir>', 'MCP output directory', 'docs/mcp')
|
||||
.option('--out <dir>', 'Human docs output directory', 'site/lib')
|
||||
.option('--plugin <name...>', 'TypeDoc plugins to enable')
|
||||
.option('--strict', 'Fail on TypeScript errors instead of generating anyway')
|
||||
.action(async (opts) => {
|
||||
const entry = opts.module || opts.entry;
|
||||
if (!entry) {
|
||||
throw new Error('Must specify --module or --entry');
|
||||
}
|
||||
|
||||
const entryAbs = path.resolve(entry);
|
||||
const projectName =
|
||||
opts.projectName ||
|
||||
opts.name ||
|
||||
(await inferProjectName(entryAbs));
|
||||
|
||||
const wantMcp = opts.mcp;
|
||||
const wantDocs = opts.docs || opts.mkdocs || opts.lib;
|
||||
const wantApi = opts.api;
|
||||
const wantWiki = opts.wiki;
|
||||
|
||||
if (!wantMcp && !wantDocs && !wantApi && !wantWiki) {
|
||||
throw new Error('Must specify at least one of --mcp, --docs, --api, --wiki');
|
||||
}
|
||||
if (wantApi || wantWiki) {
|
||||
throw new Error('--api and --wiki are reserved and not yet implemented');
|
||||
}
|
||||
|
||||
const tsconfig = resolveTsconfig(entryAbs, opts.tsconfig);
|
||||
// Directory entries expand to every file; a single file uses resolve.
|
||||
const entryPointStrategy = fs.statSync(entryAbs).isDirectory() ? 'expand' : 'resolve';
|
||||
|
||||
if (wantMcp) {
|
||||
const json = await runTypeDocJson({
|
||||
entryPoints: [entryAbs],
|
||||
tsconfig,
|
||||
name: projectName,
|
||||
skipErrorChecking: !opts.strict,
|
||||
entryPointStrategy
|
||||
});
|
||||
const outDir = path.resolve(opts.outDir);
|
||||
await generateMcpResources(json, projectName, outDir);
|
||||
console.log(`MCP resources written to ${outDir}`);
|
||||
}
|
||||
|
||||
if (wantDocs) {
|
||||
const out = await generateHumanDocs({
|
||||
entryPoints: [entryAbs],
|
||||
tsconfig,
|
||||
name: projectName,
|
||||
out: path.resolve(opts.out),
|
||||
plugin: opts.plugin,
|
||||
skipErrorChecking: !opts.strict,
|
||||
entryPointStrategy
|
||||
});
|
||||
console.log(`Human docs written to ${out}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Serve documentation locally. */
|
||||
export function serveCommand(): Command {
|
||||
return new Command('serve')
|
||||
.description('Serve documentation locally')
|
||||
.option('--mcp', 'Serve the generated MCP bundle')
|
||||
.option('--module <name>', 'Module name used to label the MCP server')
|
||||
.option('--out-dir <dir>', 'MCP root directory', 'docs/mcp')
|
||||
.action(async (opts) => {
|
||||
if (!opts.mcp) {
|
||||
throw new Error('Must specify --mcp');
|
||||
}
|
||||
if (!opts.module) {
|
||||
throw new Error('--module is required for MCP serve');
|
||||
}
|
||||
await serveMcp(opts.module, path.resolve(opts.outDir));
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTsconfig(entryAbs: string, explicit?: string): string | undefined {
|
||||
if (explicit) {
|
||||
return fs.existsSync(explicit) ? path.resolve(explicit) : undefined;
|
||||
}
|
||||
let dir = path.dirname(entryAbs);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = path.join(dir, 'tsconfig.json');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function inferProjectName(entryAbs: string): Promise<string> {
|
||||
let dir = path.dirname(entryAbs);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const pkg = path.join(dir, 'package.json');
|
||||
if (await fs.pathExists(pkg)) {
|
||||
try {
|
||||
const data = await fs.readJSON(pkg);
|
||||
if (data?.name) return data.name;
|
||||
} catch {
|
||||
// ignore malformed package.json
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.basename(path.dirname(entryAbs));
|
||||
}
|
||||
24
src/cli/index.ts
Normal file
24
src/cli/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Command } from 'commander';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { buildCommand, serveCommand } from './commands.js';
|
||||
|
||||
export function main(): void {
|
||||
const program = new Command();
|
||||
program
|
||||
.name('doc-weaver')
|
||||
.description('TypeScript documentation compiler: MkDocs + MCP (doc-forge compatible)')
|
||||
.version('0.1.0');
|
||||
|
||||
program.addCommand(buildCommand());
|
||||
program.addCommand(serveCommand());
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Allow `tsx src/cli/index.ts` and `node dist/cli/index.js` to run directly.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
41
src/cli/mcp-utils.ts
Normal file
41
src/cli/mcp-utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TypeDocJsonToMCP } from '../renderers/mcp-renderer.js';
|
||||
import { MCPServer } from '../servers/mcp-server.js';
|
||||
import * as path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/** Generate the doc-forge-compatible MCP bundle from TypeDoc reflection JSON. */
|
||||
export async function generateMcpResources(
|
||||
typedocJson: any,
|
||||
projectName: string | undefined,
|
||||
outDir: string
|
||||
): Promise<void> {
|
||||
const transformer = new TypeDocJsonToMCP(typedocJson, projectName);
|
||||
const { index, nav, modules } = transformer.transform();
|
||||
|
||||
await fs.ensureDir(path.join(outDir, 'modules'));
|
||||
await fs.writeJSON(path.join(outDir, 'index.json'), index, { spaces: 2 });
|
||||
await fs.writeJSON(path.join(outDir, 'nav.json'), nav, { spaces: 2 });
|
||||
|
||||
for (const [modPath, modData] of Object.entries(modules)) {
|
||||
const outPath = path.join(outDir, 'modules', modPath + '.json');
|
||||
await fs.ensureDir(path.dirname(outPath));
|
||||
await fs.writeJSON(outPath, modData, { spaces: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Serve a pre-generated MCP bundle over stdio/HTTP. */
|
||||
export async function serveMcp(module: string, mcpRoot: string): Promise<void> {
|
||||
const root = path.resolve(mcpRoot);
|
||||
const required = [
|
||||
path.join(root, 'index.json'),
|
||||
path.join(root, 'nav.json'),
|
||||
path.join(root, 'modules')
|
||||
];
|
||||
for (const r of required) {
|
||||
if (!(await fs.pathExists(r))) {
|
||||
throw new Error('Invalid MCP bundle, missing: ' + path.basename(r));
|
||||
}
|
||||
}
|
||||
const server = new MCPServer(root, module + '-mcp');
|
||||
await server.run();
|
||||
}
|
||||
29
src/index.ts
Normal file
29
src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* # doc-weaver
|
||||
*
|
||||
* Public library surface for doc-weaver, the TypeScript counterpart to
|
||||
* `doc-forge`. It compiles TypeDoc reflection (built from TypeScript + JSDoc)
|
||||
* into two renderers:
|
||||
*
|
||||
* - a human-facing TypeDoc site (the MkDocs parallel), and
|
||||
* - a machine-facing MCP JSON bundle (`index.json`, `nav.json`, `modules/`)
|
||||
* whose shape is byte-compatible with `doc-forge`.
|
||||
*
|
||||
* Programmatic usage:
|
||||
*
|
||||
* import { runTypeDocJson, TypeDocJsonToMCP } from 'doc-weaver';
|
||||
* const json = await runTypeDocJson({ entryPoints: ['src/index.ts'] });
|
||||
* const bundle = new TypeDocJsonToMCP(json, 'my-app').transform();
|
||||
*/
|
||||
|
||||
export { main } from './cli/index.js';
|
||||
export { runTypeDocJson, defaultTsconfigFor } from './loaders/typedoc-loader.js';
|
||||
export type { TypeDocOptions } from './loaders/typedoc-loader.js';
|
||||
export { generateHumanDocs } from './renderers/human-docs.js';
|
||||
export type { HumanDocsOptions } from './renderers/human-docs.js';
|
||||
export { TypeDocJsonToMCP } from './renderers/mcp-renderer.js';
|
||||
export type { McpBundle } from './renderers/mcp-renderer.js';
|
||||
export { generateMcpResources, serveMcp } from './cli/mcp-utils.js';
|
||||
export { MCPServer } from './servers/mcp-server.js';
|
||||
export { collectModules, resolveModulePath, RefKind } from './models/typedoc.js';
|
||||
export type { DocObject, DocModule, DocProject, DocKind } from './models/index.js';
|
||||
61
src/loaders/typedoc-loader.ts
Normal file
61
src/loaders/typedoc-loader.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Application, TSConfigReader } from 'typedoc';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface TypeDocOptions {
|
||||
entryPoints: string[];
|
||||
tsconfig?: string;
|
||||
name?: string;
|
||||
/** Optional TypeDoc theme/plugin passthroughs */
|
||||
plugin?: string[];
|
||||
/**
|
||||
* Skip TypeScript error checking before conversion. Defaults to true so
|
||||
* documentation still generates when a project has unrelated type errors.
|
||||
*/
|
||||
skipErrorChecking?: boolean;
|
||||
/** TypeDoc entry-point strategy. Defaults to 'resolve'. */
|
||||
entryPointStrategy?: 'resolve' | 'expand' | 'merge' | 'packages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run TypeDoc programmatically and return the serialized reflection JSON.
|
||||
*
|
||||
* Mirrors doc-forge's static loader: no user code is executed for
|
||||
* documentation semantics; we rely on TypeScript's compiler API.
|
||||
*/
|
||||
export async function runTypeDocJson(opts: TypeDocOptions): Promise<any> {
|
||||
const basePath = opts.tsconfig ? path.dirname(opts.tsconfig) : opts.entryPoints[0];
|
||||
const app = await Application.bootstrapWithPlugins(
|
||||
{
|
||||
entryPoints: opts.entryPoints,
|
||||
tsconfig: opts.tsconfig,
|
||||
name: opts.name,
|
||||
plugin: opts.plugin,
|
||||
basePath,
|
||||
// Keep things deterministic and quiet
|
||||
readme: 'none',
|
||||
entryPointStrategy: opts.entryPointStrategy ?? 'resolve',
|
||||
excludePrivate: false,
|
||||
excludeInternal: false,
|
||||
skipErrorChecking: opts.skipErrorChecking ?? true
|
||||
},
|
||||
[new TSConfigReader()]
|
||||
);
|
||||
|
||||
const project = await app.convert();
|
||||
if (!project) {
|
||||
throw new Error('TypeDoc failed to produce a project reflection');
|
||||
}
|
||||
|
||||
// TypeDoc >= 0.26 exposes serializer.projectToObject(root, projectRoot).
|
||||
const serializer: any = (app as any).serializer;
|
||||
if (serializer && typeof serializer.projectToObject === 'function') {
|
||||
return serializer.projectToObject(project, basePath);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported TypeDoc version: serializer.projectToObject is unavailable');
|
||||
}
|
||||
|
||||
/** Convenience: resolve tsconfig relative to a given project root. */
|
||||
export function defaultTsconfigFor(root: string): string {
|
||||
return path.join(root, 'tsconfig.json');
|
||||
}
|
||||
52
src/models/index.ts
Normal file
52
src/models/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Semantic documentation model for doc-weaver.
|
||||
*
|
||||
* This mirrors doc-forge's renderer-agnostic model: a project is a
|
||||
* collection of modules, each module contains named documentation objects,
|
||||
* and objects may contain nested members. Renderers (MkDocs, MCP) consume
|
||||
* this model so that output formats never depend on each other.
|
||||
*/
|
||||
|
||||
/** The set of documentable symbol kinds recognized by doc-weaver. */
|
||||
export type DocKind =
|
||||
| 'module'
|
||||
| 'namespace'
|
||||
| 'class'
|
||||
| 'interface'
|
||||
| 'enum'
|
||||
| 'enum-member'
|
||||
| 'type-alias'
|
||||
| 'variable'
|
||||
| 'function'
|
||||
| 'method'
|
||||
| 'accessor'
|
||||
| 'property'
|
||||
| 'constructor'
|
||||
| 'signature'
|
||||
| 'parameter'
|
||||
| 'component'
|
||||
| 'hook'
|
||||
| 'unknown';
|
||||
|
||||
/** A documentation object with an optional tree of members. */
|
||||
export interface DocObject {
|
||||
name: string;
|
||||
kind: DocKind;
|
||||
path: string;
|
||||
signature: string;
|
||||
docstring: string;
|
||||
members?: Record<string, DocObject>;
|
||||
}
|
||||
|
||||
/** A single module (typically one source file or package). */
|
||||
export interface DocModule {
|
||||
path: string;
|
||||
docstring: string;
|
||||
objects: Record<string, DocObject>;
|
||||
}
|
||||
|
||||
/** The root semantic model produced by the loader. */
|
||||
export interface DocProject {
|
||||
name: string;
|
||||
modules: DocModule[];
|
||||
}
|
||||
66
src/models/typedoc.ts
Normal file
66
src/models/typedoc.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ReflectionKind } from 'typedoc';
|
||||
|
||||
/** Re-export TypeDoc's numeric reflection kinds for internal use. */
|
||||
export const RefKind = ReflectionKind;
|
||||
|
||||
/**
|
||||
* Recursively collect every Module-kind reflection in a TypeDoc project.
|
||||
*
|
||||
* TypeDoc emits Module reflections for each resolved entry point (one per
|
||||
* source file/package). When a project has no module children (a single flat
|
||||
* entry point), the project itself is treated as a single module so that the
|
||||
* output always has at least one module - matching doc-forge's model.
|
||||
*/
|
||||
export function collectModules(project: any): any[] {
|
||||
const modules: any[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (!node) return;
|
||||
if (node.kind === RefKind.Module) {
|
||||
modules.push(node);
|
||||
return;
|
||||
}
|
||||
for (const child of node.children || []) walk(child);
|
||||
};
|
||||
walk(project);
|
||||
|
||||
if (modules.length > 0) return modules;
|
||||
return [project];
|
||||
}
|
||||
|
||||
/** Derive a stable, filesystem-safe module path from a TypeDoc reflection. */
|
||||
export function resolveModulePath(mod: any, project: any): string {
|
||||
const source = mod.sources?.[0]?.fileName;
|
||||
if (typeof source === 'string' && source.length) {
|
||||
return normalizeSourcePath(source);
|
||||
}
|
||||
if (mod.kind === RefKind.Project) {
|
||||
return sanitize(mod.name || project?.name || 'project');
|
||||
}
|
||||
return sanitize(mod.name || 'module');
|
||||
}
|
||||
|
||||
function normalizeSourcePath(fileName: string): string {
|
||||
let p = fileName.replace(/\\/g, '/');
|
||||
p = p.replace(/^\.\//, '');
|
||||
p = p.replace(/^src\//, '');
|
||||
p = p.replace(/\.(tsx?|jsx?|mts|cts|mjs|cjs)$/, '');
|
||||
p = p.replace(/\/index$/, '');
|
||||
// Use dotted paths so MCP resource templates match a single URI segment,
|
||||
// mirroring doc-forge's dotted Python module paths.
|
||||
p = p.replace(/\//g, '.');
|
||||
return sanitize(p);
|
||||
}
|
||||
|
||||
/** Replace characters that are unsafe in a resource path / filename. */
|
||||
export function sanitize(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '.')
|
||||
.replace(/[^A-Za-z0-9_.-]/g, '_')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Human-readable name for a reflective kind (used in diagnostics). */
|
||||
export function reflectionKindName(kind: number): string {
|
||||
return ReflectionKind[kind] ?? String(kind);
|
||||
}
|
||||
115
src/renderers/docstring.ts
Normal file
115
src/renderers/docstring.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ReflectionKind } from 'typedoc';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* receive the same human-readable documentation.
|
||||
*/
|
||||
export function renderComment(comment: any): string {
|
||||
if (!comment) return '';
|
||||
const lines: string[] = [];
|
||||
|
||||
const summary = renderParts(comment.summary);
|
||||
if (summary.trim()) lines.push(summary.trim());
|
||||
|
||||
for (const tag of comment.blockTags || []) {
|
||||
const tagName = (tag.tag || '').replace(/^@/, '');
|
||||
const content = renderParts(tag.content).trim();
|
||||
if (!content) continue;
|
||||
const heading = tagHeading(tagName);
|
||||
lines.push('');
|
||||
lines.push(heading);
|
||||
lines.push('');
|
||||
lines.push(content);
|
||||
}
|
||||
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
function tagHeading(tag: string): string {
|
||||
switch (tag) {
|
||||
case 'param':
|
||||
return '**Parameters:**';
|
||||
case 'returns':
|
||||
case 'return':
|
||||
return '**Returns:**';
|
||||
case 'example':
|
||||
return '**Example:**';
|
||||
case 'throws':
|
||||
return '**Throws:**';
|
||||
case 'deprecated':
|
||||
return '**Deprecated:**';
|
||||
case 'see':
|
||||
return '**See also:**';
|
||||
case 'remarks':
|
||||
return '**Remarks:**';
|
||||
default:
|
||||
return '**@' + tag + ':**';
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a list of TypeDoc comment display parts into markdown. */
|
||||
export function renderParts(parts: any[] | undefined): string {
|
||||
if (!parts || !Array.isArray(parts)) return '';
|
||||
const out: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
out.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!part) continue;
|
||||
out.push(part.text ?? '');
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** Map TypeDoc numerical ReflectionKind to doc-weaver DocKind. */
|
||||
export function mapKind(kind: number): string {
|
||||
switch (kind) {
|
||||
case ReflectionKind.Module:
|
||||
return 'module';
|
||||
case ReflectionKind.Namespace:
|
||||
return 'namespace';
|
||||
case ReflectionKind.Class:
|
||||
return 'class';
|
||||
case ReflectionKind.Interface:
|
||||
return 'interface';
|
||||
case ReflectionKind.Enum:
|
||||
return 'enum';
|
||||
case ReflectionKind.EnumMember:
|
||||
return 'enum-member';
|
||||
case ReflectionKind.TypeAlias:
|
||||
return 'type-alias';
|
||||
case ReflectionKind.Variable:
|
||||
return 'variable';
|
||||
case ReflectionKind.Function:
|
||||
return 'function';
|
||||
case ReflectionKind.Method:
|
||||
return 'method';
|
||||
case ReflectionKind.Accessor:
|
||||
return 'accessor';
|
||||
case ReflectionKind.Property:
|
||||
return 'property';
|
||||
case ReflectionKind.Constructor:
|
||||
return 'constructor';
|
||||
case ReflectionKind.CallSignature:
|
||||
case ReflectionKind.ConstructorSignature:
|
||||
return 'signature';
|
||||
case ReflectionKind.Parameter:
|
||||
return 'parameter';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a symbol as a React component or hook where possible. */
|
||||
export function classifyReact(name: string, kind: string): string {
|
||||
if (kind === 'function' || kind === 'variable') {
|
||||
if (/^use[A-Z0-9]/.test(name)) return 'hook';
|
||||
if (/^[A-Z]/.test(name)) return 'component';
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
50
src/renderers/human-docs.ts
Normal file
50
src/renderers/human-docs.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Application, TSConfigReader } from 'typedoc';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Generate the human-facing documentation site using TypeDoc.
|
||||
*
|
||||
* TypeDoc plays the same role for doc-weaver that MkDocs plays for
|
||||
* doc-forge: it renders the compiled model into a browsable site. The
|
||||
* machine-facing output (MCP JSON) is produced separately.
|
||||
*/
|
||||
export interface HumanDocsOptions {
|
||||
entryPoints: string[];
|
||||
tsconfig?: string;
|
||||
name?: string;
|
||||
out: string;
|
||||
/** Optional path to a typedoc.json config. */
|
||||
options?: string;
|
||||
/** Optional TypeDoc plugins (e.g. typedoc-plugin-markdown). */
|
||||
plugin?: string[];
|
||||
/** Skip TypeScript error checking. Defaults to true. */
|
||||
skipErrorChecking?: boolean;
|
||||
/** TypeDoc entry-point strategy. Defaults to 'resolve'. */
|
||||
entryPointStrategy?: 'resolve' | 'expand' | 'merge' | 'packages';
|
||||
}
|
||||
|
||||
export async function generateHumanDocs(opts: HumanDocsOptions): Promise<string> {
|
||||
const basePath = opts.tsconfig ? path.dirname(opts.tsconfig) : opts.entryPoints[0];
|
||||
const app = await Application.bootstrapWithPlugins(
|
||||
{
|
||||
entryPoints: opts.entryPoints,
|
||||
tsconfig: opts.tsconfig,
|
||||
name: opts.name,
|
||||
out: opts.out,
|
||||
options: opts.options,
|
||||
plugin: opts.plugin,
|
||||
basePath,
|
||||
readme: 'none',
|
||||
entryPointStrategy: opts.entryPointStrategy ?? 'resolve',
|
||||
skipErrorChecking: opts.skipErrorChecking ?? true
|
||||
},
|
||||
[new TSConfigReader()]
|
||||
);
|
||||
|
||||
const project = await app.convert();
|
||||
if (!project) {
|
||||
throw new Error('TypeDoc failed to convert the project');
|
||||
}
|
||||
await app.generateDocs(project, opts.out);
|
||||
return path.resolve(opts.out);
|
||||
}
|
||||
113
src/renderers/mcp-renderer.ts
Normal file
113
src/renderers/mcp-renderer.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
RefKind,
|
||||
reflectionKindName,
|
||||
resolveModulePath,
|
||||
collectModules
|
||||
} from '../models/typedoc.js';
|
||||
import { renderComment, mapKind, classifyReact } from './docstring.js';
|
||||
import { bestSignature } from './signature.js';
|
||||
|
||||
export interface McpBundle {
|
||||
index: Record<string, unknown>;
|
||||
nav: Array<{ module: string; resource: string }>;
|
||||
modules: Record<string, { module: string; content: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a TypeDoc serialized reflection JSON into a doc-forge-compatible
|
||||
* MCP bundle.
|
||||
*
|
||||
* The produced shape matches doc-forge exactly:
|
||||
*
|
||||
* - `index.json` -> `{ project, type, modules_count, source }`
|
||||
* - `nav.json` -> `[ { module, resource } ]` with `docs://modules/{module}`
|
||||
* - `modules/*.json` -> `{ module, content: { path, docstring, objects } }`
|
||||
*
|
||||
* Objects are rendered recursively with
|
||||
* `{ name, kind, path, signature, docstring, members? }`.
|
||||
*/
|
||||
export class TypeDocJsonToMCP {
|
||||
constructor(
|
||||
private readonly project: any,
|
||||
private readonly projectName?: string
|
||||
) {}
|
||||
|
||||
transform(): McpBundle {
|
||||
const name = this.projectName || this.project?.name || 'project';
|
||||
const moduleRefs = collectModules(this.project);
|
||||
|
||||
const nav: McpBundle['nav'] = [];
|
||||
const modules: McpBundle['modules'] = {};
|
||||
|
||||
for (const mod of moduleRefs) {
|
||||
const modPath = resolveModulePath(mod, this.project);
|
||||
nav.push({ module: modPath, resource: `docs://modules/${modPath}` });
|
||||
modules[modPath] = {
|
||||
module: modPath,
|
||||
content: this.renderModule(mod, modPath)
|
||||
};
|
||||
}
|
||||
|
||||
const index = {
|
||||
project: name,
|
||||
type: 'docforge-model',
|
||||
modules_count: nav.length,
|
||||
source: 'doc-weaver'
|
||||
};
|
||||
|
||||
return { index, nav, modules };
|
||||
}
|
||||
|
||||
private renderModule(mod: any, modPath: string): Record<string, unknown> {
|
||||
const objects: Record<string, unknown> = {};
|
||||
for (const child of mod.children || []) {
|
||||
if (child.kind === RefKind.Module) continue;
|
||||
objects[child.name] = this.renderObject(child, modPath);
|
||||
}
|
||||
return {
|
||||
path: modPath,
|
||||
docstring: renderComment(mod.comment),
|
||||
objects
|
||||
};
|
||||
}
|
||||
|
||||
private renderObject(ref: any, parentPath: string): Record<string, unknown> {
|
||||
const kindName = classifyReact(ref.name, mapKind(ref.kind));
|
||||
const path = `${parentPath}.${ref.name}`;
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
name: ref.name,
|
||||
kind: kindName,
|
||||
path,
|
||||
signature: bestSignature(ref),
|
||||
docstring: renderComment(ref.comment)
|
||||
};
|
||||
|
||||
// Prefer an explicit @kind tag when present (mirrors doc-forge kinds).
|
||||
const kindTag = findBlockTag(ref.comment, '@kind');
|
||||
if (kindTag) data.kind = kindTag;
|
||||
|
||||
const members = this.collectMembers(ref, path);
|
||||
if (Object.keys(members).length) data.members = members;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private collectMembers(ref: any, parentPath: string): Record<string, unknown> {
|
||||
const members: Record<string, unknown> = {};
|
||||
for (const child of ref.children || []) {
|
||||
members[child.name] = this.renderObject(child, parentPath);
|
||||
}
|
||||
// Enum members and similar leaf children are already handled by children.
|
||||
return members;
|
||||
}
|
||||
}
|
||||
|
||||
function findBlockTag(comment: any, tag: string): string | undefined {
|
||||
for (const bt of comment?.blockTags || []) {
|
||||
if (bt.tag === tag) {
|
||||
return (bt.content || []).map((p: any) => p.text ?? '').join('').trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
90
src/renderers/signature.ts
Normal file
90
src/renderers/signature.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Utilities for reconstructing human-readable signatures from TypeDoc JSON. */
|
||||
|
||||
/** Render a TypeDoc type object to a compact TypeScript-like string. */
|
||||
export function renderType(type: any): string {
|
||||
if (!type) return 'unknown';
|
||||
if (typeof type === 'string') return type;
|
||||
|
||||
switch (type.type) {
|
||||
case 'intrinsic':
|
||||
return type.name ?? 'unknown';
|
||||
case 'reference': {
|
||||
const name = type.name ?? 'unknown';
|
||||
const args = (type.typeArguments || []).map(renderType);
|
||||
const qualified =
|
||||
type.qualifiedName && type.qualifiedName !== name ? type.qualifiedName : name;
|
||||
return args.length ? `${qualified}<${args.join(', ')}>` : qualified;
|
||||
}
|
||||
case 'array':
|
||||
return `${renderType(type.elementType)}[]`;
|
||||
case 'union':
|
||||
return (type.types || []).map(renderType).join(' | ');
|
||||
case 'intersection':
|
||||
return (type.types || []).map(renderType).join(' & ');
|
||||
case 'tuple':
|
||||
return `[${(type.elements || []).map(renderType).join(', ')}]`;
|
||||
case 'literal':
|
||||
return type.value === null ? 'null' : JSON.stringify(type.value);
|
||||
case 'reflection':
|
||||
if (type.declaration?.signatures?.length) {
|
||||
const sig = type.declaration.signatures[0];
|
||||
return sig.type ? renderType(sig.type) : 'function';
|
||||
}
|
||||
if (type.declaration?.children?.length) {
|
||||
const members = type.declaration.children
|
||||
.map((c: any) => `${c.name}: ${renderType(c.type)}`)
|
||||
.join('; ');
|
||||
return `{ ${members} }`;
|
||||
}
|
||||
return 'object';
|
||||
case 'typeParameter':
|
||||
return type.name ?? 'T';
|
||||
case 'predicate':
|
||||
return type.name ?? 'boolean';
|
||||
case 'query':
|
||||
return `typeof ${renderType(type.queryType)}`;
|
||||
case 'rest':
|
||||
return `...${renderType(type.elementType)}`;
|
||||
case 'namedTupleMember':
|
||||
return `${type.name}: ${renderType(type.elementType)}`;
|
||||
case 'optional':
|
||||
return `${renderType(type.elementType)}?`;
|
||||
default:
|
||||
return type.name ?? 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a function/call signature to a TypeScript-like string. */
|
||||
export function renderSignature(signature: any, fallbackName?: string): string {
|
||||
if (!signature) return fallbackName ?? '';
|
||||
const name = signature.name ?? fallbackName ?? '';
|
||||
const params = (signature.parameters || [])
|
||||
.map((p: any) => {
|
||||
const optional = p.flags?.isOptional ? '?' : '';
|
||||
return `${p.name}${optional}: ${renderType(p.type)}`;
|
||||
})
|
||||
.join(', ');
|
||||
const returns = signature.type ? `: ${renderType(signature.type)}` : '';
|
||||
return `${name}(${params})${returns}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best signature string for a reflection.
|
||||
*
|
||||
* Functions/methods/constructors keep their declared signatures; variables
|
||||
* and properties expose their type; classes fall back to their name.
|
||||
*/
|
||||
export function bestSignature(ref: any): string {
|
||||
if (ref.signatures?.length) {
|
||||
return ref.signatures.map((s: any) => renderSignature(s, ref.name)).join(' | ');
|
||||
}
|
||||
if (ref.getSignature || ref.setSignature) {
|
||||
const get = ref.getSignature ? renderSignature(ref.getSignature, ref.name) : '';
|
||||
const set = ref.setSignature ? `set ${renderSignature(ref.setSignature, ref.name)}` : '';
|
||||
return [get, set].filter(Boolean).join(' | ');
|
||||
}
|
||||
if (ref.type) {
|
||||
return `${ref.name}: ${renderType(ref.type)}`;
|
||||
}
|
||||
return ref.name ?? '';
|
||||
}
|
||||
82
src/servers/mcp-server.ts
Normal file
82
src/servers/mcp-server.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import * as path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/**
|
||||
* MCP server serving a pre-generated doc-weaver bundle.
|
||||
*
|
||||
* Mirrors `docforge.servers.MCPServer`: it exposes read-only documentation
|
||||
* resources and a single health-check tool.
|
||||
*
|
||||
* Resources:
|
||||
* - `docs://index` - project metadata
|
||||
* - `docs://nav` - navigation structure
|
||||
* - `docs://modules/{module}` - individual module documentation
|
||||
*/
|
||||
export class MCPServer {
|
||||
private readonly server: McpServer;
|
||||
|
||||
constructor(
|
||||
private readonly mcpRoot: string,
|
||||
name: string
|
||||
) {
|
||||
this.server = new McpServer({ name, version: '0.1.0' });
|
||||
this.registerResources();
|
||||
this.registerTools();
|
||||
}
|
||||
|
||||
private readJson(
|
||||
relPath: string,
|
||||
uri: string
|
||||
): { contents: Array<{ uri: string; mimeType?: string; text: string }> } {
|
||||
const full = path.join(this.mcpRoot, relPath);
|
||||
let text: string;
|
||||
if (fs.existsSync(full)) {
|
||||
text = fs.readFileSync(full, 'utf-8');
|
||||
} else {
|
||||
text = JSON.stringify({ error: 'not_found', path: full }, null, 2);
|
||||
}
|
||||
return { contents: [{ uri, mimeType: 'application/json', text }] };
|
||||
}
|
||||
|
||||
private registerResources(): void {
|
||||
this.server.registerResource(
|
||||
'index',
|
||||
'docs://index',
|
||||
{ title: 'Index', mimeType: 'application/json' },
|
||||
async (uri) => this.readJson('index.json', uri.href)
|
||||
);
|
||||
|
||||
this.server.registerResource(
|
||||
'nav',
|
||||
'docs://nav',
|
||||
{ title: 'Navigation', mimeType: 'application/json' },
|
||||
async (uri) => this.readJson('nav.json', uri.href)
|
||||
);
|
||||
|
||||
const template = new ResourceTemplate('docs://modules/{module}', { list: undefined });
|
||||
this.server.registerResource(
|
||||
'module',
|
||||
template,
|
||||
{ title: 'Module', mimeType: 'application/json' },
|
||||
async (uri, variables) => {
|
||||
const module = String(variables.module);
|
||||
return this.readJson(path.join('modules', module + '.json'), uri.href);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private registerTools(): void {
|
||||
this.server.registerTool(
|
||||
'ping',
|
||||
{ description: 'Health check that returns "pong".' },
|
||||
async () => ({ content: [{ type: 'text', text: 'pong' }] })
|
||||
);
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user