From ec231c06840f2babc691d0049919c7e7ccd5e67c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Jul 2025 16:49:32 +0100 Subject: [PATCH] feat: implement link graph visualization with multiple output formats - Add LinkGraphGenerator class for analyzing markdown file relationships - Support JSON, Mermaid, GraphViz DOT, and interactive HTML outputs - Include hub detection, orphan detection, and circular reference analysis - Add graph CLI command with comprehensive options - Export programmatic API for generating link graphs - All tests passing (610 tests) Resolves #21 --- src/cli.test.ts | 4 +- src/cli.ts | 46 ++ src/commands/graph.ts | 325 +++++++++++++ src/core/link-graph-generator.ts | 755 +++++++++++++++++++++++++++++ src/generated/ajv-validators.ts | 670 +++++++++++++------------- src/generated/api-routes.ts | 799 +++++++++++++++---------------- src/generated/mcp-tools.ts | 333 +++++++------ src/index.ts | 60 +++ 8 files changed, 2100 insertions(+), 892 deletions(-) create mode 100644 src/commands/graph.ts create mode 100644 src/core/link-graph-generator.ts diff --git a/src/cli.test.ts b/src/cli.test.ts index 1a25b9b..26589e8 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -146,8 +146,8 @@ describe('CLI Entry Point', () => { it('should set action handlers for commands', async () => { await import('./cli.js'); - // Should call action 9 times (once for each command: convert, move, split, join, merge, index, barrel, toc, validate) - expect(mockAction).toHaveBeenCalledTimes(9); + // Should call action 10 times (once for each command: convert, move, split, join, merge, index, barrel, toc, validate, graph) + expect(mockAction).toHaveBeenCalledTimes(10); }); it('should add help text for convert command', async () => { diff --git a/src/cli.ts b/src/cli.ts index f8b9b08..5bb5c99 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { convertCommand } from './commands/convert.js'; +import { graphCommand } from './commands/graph.js'; import { indexCommand } from './commands/index.js'; import { joinCommand } from './commands/join.js'; import { mergeCommand } from './commands/merge.js'; @@ -309,4 +310,49 @@ Output Options: ) .action(validateCommand); +program + .command('graph') + .description('Generate interactive link graphs from markdown file relationships') + .argument( + '[files...]', + 'Markdown files to analyze (supports globs like *.md, **/*.md, defaults to current directory)' + ) + .option('-f, --format ', 'Output format: json|mermaid|dot|html', 'json') + .option('-o, --output ', 'Output file path') + .option('--include-external', 'Include external links in the graph', false) + .option('--include-images', 'Include image links in the graph', true) + .option('--include-anchors', 'Include anchor links in the graph', false) + .option('--max-depth ', 'Maximum depth for dependency traversal', parseInt, 10) + .option('--base-dir ', 'Base directory for relative path calculations') + .option('-v, --verbose', 'Show detailed output with processing information') + .option('--json', 'Output results in JSON format') + .addHelpText( + 'after', + ` +Examples: + $ markmv graph # Generate JSON graph for current directory + $ markmv graph docs/**/*.md --format mermaid # Create Mermaid diagram + $ markmv graph . --format html --output viz.html # Interactive HTML visualization + $ markmv graph **/*.md --format dot --output graph.dot # GraphViz DOT format + +Output Formats: + json JSON data structure for programmatic use + mermaid Mermaid diagram syntax for documentation + dot GraphViz DOT format for advanced layouts + html Interactive D3.js visualization + +Graph Options: + --include-external Include HTTP/HTTPS links + --include-images Include image references (default: true) + --include-anchors Include same-file section links + --max-depth Limit dependency traversal depth + +Analysis Features: + • Hub detection (highly connected files) + • Orphan detection (unconnected files) + • Circular reference detection + • Strongly connected components` + ) + .action(graphCommand); + program.parse(); diff --git a/src/commands/graph.ts b/src/commands/graph.ts new file mode 100644 index 0000000..5ed0b03 --- /dev/null +++ b/src/commands/graph.ts @@ -0,0 +1,325 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { LinkGraphGenerator } from '../core/link-graph-generator.js'; +import type { GraphOutputFormat, LinkGraphOptions } from '../core/link-graph-generator.js'; +import type { OperationOptions } from '../types/operations.js'; + +/** + * Configuration options for graph generation operations. + * + * @category Commands + */ +export interface GraphOperationOptions extends OperationOptions, LinkGraphOptions { + /** Output format for the graph */ + format: GraphOutputFormat; + /** Output file path (optional) */ + output?: string; + /** Whether to open the generated file */ + open?: boolean; +} + +/** + * CLI-specific options for the graph command. + * + * @category Commands + */ +export interface GraphCliOptions extends Omit { + /** Output format as string */ + format?: string; + /** Output results in JSON format */ + json?: boolean; +} + +/** + * Result of a graph generation operation. + * + * @category Commands + */ +export interface GraphResult { + /** Whether the operation was successful */ + success: boolean; + /** Number of files processed */ + filesProcessed: number; + /** Number of nodes in the graph */ + nodeCount: number; + /** Number of edges in the graph */ + edgeCount: number; + /** Output file path if written */ + outputFile?: string; + /** Generated graph content */ + content: string; + /** Processing time in milliseconds */ + processingTime: number; + /** Analysis results */ + analysis: { + /** Number of hub nodes */ + hubCount: number; + /** Number of orphaned nodes */ + orphanCount: number; + /** Number of circular references */ + circularReferenceCount: number; + }; + /** Any errors encountered */ + errors: string[]; + /** Warnings generated */ + warnings: string[]; +} + +/** + * Generates interactive link graphs from markdown file relationships. + * + * Creates visual representations of how markdown files link to each other, supporting + * multiple output formats including JSON data, Mermaid diagrams, GraphViz DOT, and + * interactive HTML visualizations. + * + * @example + * Basic graph generation + * ```typescript + * const result = await generateGraph(['docs/**\/*.md'], { + * format: 'mermaid', + * includeExternal: false + * }); + * + * console.log('Generated Mermaid diagram:'); + * console.log(result.content); + * ``` + * + * @example + * Generate interactive HTML visualization + * ```typescript + * const result = await generateGraph(['**\/*.md'], { + * format: 'html', + * output: 'graph.html', + * includeImages: true + * }); + * + * console.log('Interactive graph saved to: ' + result.outputFile); + * ``` + * + * @param patterns - File patterns to process (supports globs) + * @param options - Graph generation options + * + * @returns Promise resolving to graph generation results + */ +export async function generateGraph( + patterns: string[], + options: Partial = {} +): Promise { + const startTime = Date.now(); + + const opts = { + format: options.format || 'json', + includeExternal: options.includeExternal ?? false, + includeImages: options.includeImages ?? true, + includeAnchors: options.includeAnchors ?? false, + maxDepth: options.maxDepth ?? 10, + baseDir: options.baseDir ?? process.cwd(), + output: options.output, + open: options.open ?? false, + dryRun: options.dryRun ?? false, + verbose: options.verbose ?? false, + force: options.force ?? false, + }; + + const result: GraphResult = { + success: false, + filesProcessed: 0, + nodeCount: 0, + edgeCount: 0, + content: '', + processingTime: 0, + analysis: { + hubCount: 0, + orphanCount: 0, + circularReferenceCount: 0, + }, + errors: [], + warnings: [], + }; + + try { + if (opts.verbose) { + console.log('Generating ' + opts.format + ' graph for patterns: ' + patterns.join(', ')); + } + + // Initialize graph generator + const generator = new LinkGraphGenerator({ + includeExternal: opts.includeExternal, + includeImages: opts.includeImages, + includeAnchors: opts.includeAnchors, + maxDepth: opts.maxDepth, + baseDir: opts.baseDir, + }); + + // Generate the graph + const graph = await generator.generateGraph(patterns); + + // Export to requested format + const content = generator.exportGraph(graph, opts.format); + + // Update result + result.success = true; + result.filesProcessed = graph.metadata.filesProcessed; + result.nodeCount = graph.nodes.length; + result.edgeCount = graph.edges.length; + result.content = content; + result.analysis = { + hubCount: graph.analysis.hubs.length, + orphanCount: graph.analysis.orphans.length, + circularReferenceCount: graph.analysis.circularReferences.length, + }; + + // Write to file if output path specified + if (opts.output && !opts.dryRun) { + const outputPath = resolve(opts.output); + await writeFile(outputPath, content, 'utf-8'); + result.outputFile = outputPath; + + if (opts.verbose) { + console.log('Graph written to: ' + outputPath); + } + } + + // Add warnings for analysis results + if (result.analysis.circularReferenceCount > 0) { + result.warnings.push( + 'Found ' + result.analysis.circularReferenceCount + ' circular reference(s)' + ); + } + + if (result.analysis.orphanCount > 0) { + result.warnings.push( + 'Found ' + result.analysis.orphanCount + ' orphaned file(s) with no links' + ); + } + + if (opts.verbose) { + console.log('Graph generated: ' + result.nodeCount + ' nodes, ' + result.edgeCount + ' edges'); + console.log('Hubs: ' + result.analysis.hubCount + ', Orphans: ' + result.analysis.orphanCount); + } + + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + if (opts.verbose) { + console.error('Graph generation failed:', error); + } + } + + result.processingTime = Date.now() - startTime; + return result; +} + +/** + * CLI command handler for graph operations. + * + * Processes markdown files to generate interactive link graphs in various formats. + * Supports JSON data export, Mermaid diagrams, GraphViz DOT format, and interactive + * HTML visualizations. + * + * @example + * ```bash + * # Generate Mermaid diagram for all markdown files + * markmv graph "**\/*.md" --format mermaid --output graph.mmd + * + * # Create interactive HTML visualization + * markmv graph docs/ --format html --output visualization.html + * + * # Export JSON data for external processing + * markmv graph . --format json --include-external --output graph.json + * + * # Generate GraphViz DOT file + * markmv graph "**\/*.md" --format dot --include-images --output graph.dot + * ``` + * + * @param patterns - File patterns to process + * @param cliOptions - CLI-specific options + */ +export async function graphCommand( + patterns: string[], + cliOptions: GraphCliOptions +): Promise { + // Default to current directory if no patterns provided + const finalPatterns = patterns.length === 0 ? ['.'] : patterns; + + // Convert CLI options to internal options + const format = (cliOptions.format || 'json') as GraphOutputFormat; + + // Validate format + const validFormats: GraphOutputFormat[] = ['json', 'mermaid', 'dot', 'html']; + if (!validFormats.includes(format)) { + console.error('Invalid format: ' + format + '. Valid formats: ' + validFormats.join(', ')); + process.exitCode = 1; + return; + } + + const options: GraphOperationOptions = { + ...cliOptions, + format, + }; + + try { + const result = await generateGraph(finalPatterns, options); + + if (cliOptions.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Format output for human consumption + console.log('\nšŸ“Š Graph Generation Summary'); + console.log('Files processed: ' + result.filesProcessed); + console.log('Nodes: ' + result.nodeCount); + console.log('Edges: ' + result.edgeCount); + console.log('Format: ' + format); + console.log('Processing time: ' + result.processingTime + 'ms\n'); + + if (result.outputFile) { + console.log('šŸ“ Output written to: ' + result.outputFile + '\n'); + } + + // Analysis summary + console.log('šŸ” Graph Analysis:'); + console.log(' Hub nodes (high connectivity): ' + result.analysis.hubCount); + console.log(' Orphaned nodes (no connections): ' + result.analysis.orphanCount); + console.log(' Circular references: ' + result.analysis.circularReferenceCount + '\n'); + + if (result.warnings.length > 0) { + console.log('āš ļø Warnings (' + result.warnings.length + '):'); + for (const warning of result.warnings) { + console.log(' ' + warning); + } + console.log(); + } + + if (result.errors.length > 0) { + console.log('āŒ Errors (' + result.errors.length + '):'); + for (const error of result.errors) { + console.log(' ' + error); + } + console.log(); + process.exitCode = 1; + return; + } + + if (!result.success) { + console.log('āŒ Graph generation failed'); + process.exitCode = 1; + return; + } + + // Show content preview for small outputs or if no output file + if (!result.outputFile && result.content.length < 5000) { + console.log('šŸ“‹ Generated ' + format.toUpperCase() + ':'); + console.log(result.content); + } else if (!result.outputFile) { + console.log('šŸ“‹ Generated ' + format.toUpperCase() + ' (' + result.content.length + ' characters)'); + console.log(result.content.substring(0, 500) + '...'); + } + + console.log('āœ… Graph generation completed successfully!'); + + } catch (error) { + console.error('Graph generation failed:', error); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/src/core/link-graph-generator.ts b/src/core/link-graph-generator.ts new file mode 100644 index 0000000..eae2ae3 --- /dev/null +++ b/src/core/link-graph-generator.ts @@ -0,0 +1,755 @@ +import { readFile } from 'node:fs/promises'; +import { resolve, relative, dirname } from 'node:path'; +import { LinkParser } from './link-parser.js'; +import type { MarkdownLink } from '../types/links.js'; + +/** + * Configuration options for link graph generation. + * + * @category Core + */ +export interface LinkGraphOptions { + /** Include external links in the graph */ + includeExternal?: boolean; + /** Include image links in the graph */ + includeImages?: boolean; + /** Include anchor links in the graph */ + includeAnchors?: boolean; + /** Maximum depth for dependency traversal */ + maxDepth?: number; + /** Base directory for relative path calculations */ + baseDir?: string; +} + +/** + * Represents a node in the link graph. + * + * @category Core + */ +export interface GraphNode { + /** Unique identifier for the node */ + id: string; + /** Display label for the node */ + label: string; + /** Absolute file path */ + path: string; + /** Relative path from base directory */ + relativePath: string; + /** Node type */ + type: 'markdown' | 'external' | 'image' | 'directory'; + /** Node statistics */ + stats: { + /** Number of incoming links */ + inbound: number; + /** Number of outgoing links */ + outbound: number; + /** Total link count */ + total: number; + }; + /** Additional node properties */ + properties: { + /** File size in bytes (for files) */ + size?: number; + /** Whether this is a hub node (high connectivity) */ + isHub?: boolean; + /** Whether this is an orphaned node (no connections) */ + isOrphan?: boolean; + }; +} + +/** + * Represents an edge in the link graph. + * + * @category Core + */ +export interface GraphEdge { + /** Source node ID */ + source: string; + /** Target node ID */ + target: string; + /** Link type */ + type: 'internal' | 'external' | 'image' | 'anchor' | 'claude-import'; + /** Original link text */ + text?: string; + /** Line number where link appears */ + line?: number; + /** Link weight (frequency or importance) */ + weight: number; +} + +/** + * Complete link graph representation. + * + * @category Core + */ +export interface LinkGraph { + /** All nodes in the graph */ + nodes: GraphNode[]; + /** All edges in the graph */ + edges: GraphEdge[]; + /** Graph metadata */ + metadata: { + /** Total number of files processed */ + filesProcessed: number; + /** Total number of links found */ + totalLinks: number; + /** Base directory used for calculations */ + baseDir: string; + /** Generation timestamp */ + generatedAt: string; + /** Options used for generation */ + options: Required; + }; + /** Graph analysis results */ + analysis: { + /** Hub nodes (high connectivity) */ + hubs: string[]; + /** Orphaned nodes (no connections) */ + orphans: string[]; + /** Circular references detected */ + circularReferences: string[][]; + /** Strongly connected components */ + stronglyConnected: string[][]; + }; +} + +/** + * Output format for graph export. + * + * @category Core + */ +export type GraphOutputFormat = 'json' | 'mermaid' | 'dot' | 'html'; + +/** + * Generates interactive link graphs from markdown file relationships. + * + * The LinkGraphGenerator analyzes markdown files to extract internal links and builds + * directed graphs of file relationships. Supports multiple output formats including + * JSON data, Mermaid diagrams, and interactive HTML visualizations. + * + * @category Core + * + * @example + * Basic graph generation + * ```typescript + * const generator = new LinkGraphGenerator({ + * includeExternal: false, + * maxDepth: 5 + * }); + * + * const graph = await generator.generateGraph(['docs/**\/*.md']); + * console.log('Generated graph with ' + graph.nodes.length + ' nodes and ' + graph.edges.length + ' edges'); + * ``` + * + * @example + * Export to different formats + * ```typescript + * const generator = new LinkGraphGenerator(); + * const graph = await generator.generateGraph(['*.md']); + * + * // Export as JSON + * const json = generator.exportGraph(graph, 'json'); + * + * // Export as Mermaid diagram + * const mermaid = generator.exportGraph(graph, 'mermaid'); + * + * // Export as interactive HTML + * const html = generator.exportGraph(graph, 'html'); + * ``` + */ +export class LinkGraphGenerator { + private options: Required; + private parser: LinkParser; + + constructor(options: LinkGraphOptions = {}) { + this.options = { + includeExternal: options.includeExternal ?? false, + includeImages: options.includeImages ?? true, + includeAnchors: options.includeAnchors ?? false, + maxDepth: options.maxDepth ?? 10, + baseDir: options.baseDir ?? process.cwd(), + }; + this.parser = new LinkParser(); + } + + /** + * Generates a complete link graph from markdown files. + * + * @param patterns - File patterns to process (supports globs) + * @returns Promise resolving to the generated link graph + */ + async generateGraph(patterns: string[]): Promise { + + // Parse all files to extract links + const parsedFiles = await this.parseFiles(patterns); + + // Build graph nodes and edges + const { nodes, edges } = await this.buildGraph(parsedFiles); + + // Perform graph analysis + const analysis = this.analyzeGraph(nodes, edges); + + return { + nodes, + edges, + metadata: { + filesProcessed: parsedFiles.length, + totalLinks: edges.length, + baseDir: this.options.baseDir, + generatedAt: new Date().toISOString(), + options: this.options, + }, + analysis, + }; + } + + /** + * Exports a link graph to the specified format. + * + * @param graph - The link graph to export + * @param format - Output format + * @returns Formatted graph representation + */ + exportGraph(graph: LinkGraph, format: GraphOutputFormat): string { + switch (format) { + case 'json': + return this.exportToJson(graph); + case 'mermaid': + return this.exportToMermaid(graph); + case 'dot': + return this.exportToDot(graph); + case 'html': + return this.exportToHtml(graph); + default: + throw new Error('Unsupported export format: ' + format); + } + } + + private async parseFiles(patterns: string[]): Promise> { + const { glob } = await import('glob'); + const files: string[] = []; + + // Resolve file patterns + for (const pattern of patterns) { + const matches = await glob(pattern, { + absolute: true, + ignore: ['**/node_modules/**', '**/dist/**', '**/coverage/**'], + }); + files.push(...matches.filter(f => f.endsWith('.md'))); + } + + // Parse each file + const parsedFiles = []; + for (const filePath of files) { + try { + const parsed = await this.parser.parseFile(filePath); + parsedFiles.push({ + filePath, + links: parsed.links, + }); + } catch (error) { + // Skip files that cannot be parsed + console.warn('Failed to parse ' + filePath + ':', error); + } + } + + return parsedFiles; + } + + private async buildGraph(parsedFiles: Array<{ + filePath: string; + links: MarkdownLink[]; + }>): Promise<{ nodes: GraphNode[]; edges: GraphEdge[] }> { + const nodeMap = new Map(); + const edges: GraphEdge[] = []; + + // Create nodes for all source files + for (const { filePath } of parsedFiles) { + const node = await this.createNode(filePath, 'markdown'); + nodeMap.set(filePath, node); + } + + // Process links to create edges and target nodes + for (const { filePath, links } of parsedFiles) { + const sourceNode = nodeMap.get(filePath); + if (!sourceNode) continue; + + for (const link of links) { + // Filter links based on options + if (!this.shouldIncludeLink(link)) continue; + + // Create target node if it doesn't exist + const targetPath = this.resolveTargetPath(link, filePath); + if (!nodeMap.has(targetPath)) { + const targetType = this.getNodeType(link, targetPath); + const targetNode = await this.createNode(targetPath, targetType); + nodeMap.set(targetPath, targetNode); + } + + // Create edge - filter out unsupported link types + const edgeType = link.type === 'reference' ? 'internal' : link.type; + + // Type guard to ensure we only create edges with valid types + if (edgeType === 'internal' || edgeType === 'external' || edgeType === 'image' || + edgeType === 'anchor' || edgeType === 'claude-import') { + const edge: GraphEdge = { + source: sourceNode.id, + target: nodeMap.get(targetPath)!.id, + type: edgeType, + weight: 1, + }; + + if (link.text) { + edge.text = link.text; + } + if (link.line) { + edge.line = link.line; + } + + edges.push(edge); + } + } + } + + // Calculate node statistics + this.calculateNodeStats(Array.from(nodeMap.values()), edges); + + return { + nodes: Array.from(nodeMap.values()), + edges, + }; + } + + private async createNode(path: string, type: GraphNode['type']): Promise { + const relativePath = relative(this.options.baseDir, path); + const id = this.generateNodeId(path); + const label = this.generateNodeLabel(path, type); + + let size: number | undefined; + if (type === 'markdown') { + try { + const content = await readFile(path, 'utf-8'); + size = content.length; + } catch { + // File might not exist or be readable + } + } + + const node: GraphNode = { + id, + label, + path, + relativePath, + type, + stats: { + inbound: 0, + outbound: 0, + total: 0, + }, + properties: { + isHub: false, + isOrphan: false, + }, + }; + + if (size !== undefined) { + node.properties.size = size; + } + + return node; + } + + private shouldIncludeLink(link: MarkdownLink): boolean { + switch (link.type) { + case 'external': + return this.options.includeExternal; + case 'image': + return this.options.includeImages; + case 'anchor': + return this.options.includeAnchors; + case 'internal': + case 'claude-import': + return true; + case 'reference': + return false; // Skip reference links for now + default: + return false; + } + } + + private resolveTargetPath(link: MarkdownLink, sourceFile: string): string { + if (link.resolvedPath) { + return resolve(link.resolvedPath); + } + + if (link.type === 'external') { + return link.href; + } + + // Fallback: resolve relative to source file + return resolve(dirname(sourceFile), link.href); + } + + private getNodeType(link: MarkdownLink, targetPath: string): GraphNode['type'] { + if (link.type === 'external') { + return 'external'; + } + + if (link.type === 'image') { + return 'image'; + } + + if (targetPath.endsWith('.md')) { + return 'markdown'; + } + + return 'directory'; + } + + private generateNodeId(path: string): string { + return Buffer.from(path).toString('base64').replace(/[+/=]/g, ''); + } + + private generateNodeLabel(path: string, type: GraphNode['type']): string { + if (type === 'external') { + try { + const url = new URL(path); + return url.hostname; + } catch { + return path; + } + } + + return relative(this.options.baseDir, path) || path; + } + + private calculateNodeStats(nodes: GraphNode[], edges: GraphEdge[]): void { + // Reset stats + for (const node of nodes) { + node.stats.inbound = 0; + node.stats.outbound = 0; + } + + // Count edges + for (const edge of edges) { + const sourceNode = nodes.find(n => n.id === edge.source); + const targetNode = nodes.find(n => n.id === edge.target); + + if (sourceNode) { + sourceNode.stats.outbound++; + } + if (targetNode) { + targetNode.stats.inbound++; + } + } + + // Calculate totals and identify hubs/orphans + for (const node of nodes) { + node.stats.total = node.stats.inbound + node.stats.outbound; + node.properties.isHub = node.stats.total > 10; // Threshold for hub detection + node.properties.isOrphan = node.stats.total === 0; + } + } + + private analyzeGraph(nodes: GraphNode[], edges: GraphEdge[]): LinkGraph['analysis'] { + const hubs = nodes + .filter(n => n.properties.isHub) + .map(n => n.id); + + const orphans = nodes + .filter(n => n.properties.isOrphan) + .map(n => n.id); + + // Simple circular reference detection + const circularReferences = this.detectCircularReferences(nodes, edges); + + // For now, use a simple connected component algorithm + const stronglyConnected = this.findStronglyConnectedComponents(nodes, edges); + + return { + hubs, + orphans, + circularReferences, + stronglyConnected, + }; + } + + private detectCircularReferences(nodes: GraphNode[], edges: GraphEdge[]): string[][] { + const cycles: string[][] = []; + const visited = new Set(); + const recursionStack = new Set(); + + const dfs = (nodeId: string, path: string[]): void => { + if (recursionStack.has(nodeId)) { + // Found a cycle + const cycleStart = path.indexOf(nodeId); + const cycle = path.slice(cycleStart).concat(nodeId); + cycles.push(cycle); + return; + } + + if (visited.has(nodeId)) { + return; + } + + visited.add(nodeId); + recursionStack.add(nodeId); + + // Find outgoing edges + const outgoingEdges = edges.filter(e => e.source === nodeId); + for (const edge of outgoingEdges) { + dfs(edge.target, [...path, nodeId]); + } + + recursionStack.delete(nodeId); + }; + + for (const node of nodes) { + if (!visited.has(node.id)) { + dfs(node.id, []); + } + } + + return cycles; + } + + private findStronglyConnectedComponents(nodes: GraphNode[], edges: GraphEdge[]): string[][] { + // Simplified implementation - just return connected components + const components: string[][] = []; + const visited = new Set(); + + const dfs = (nodeId: string, component: string[]): void => { + if (visited.has(nodeId)) { + return; + } + + visited.add(nodeId); + component.push(nodeId); + + // Find all connected nodes (both directions) + const connectedEdges = edges.filter( + e => e.source === nodeId || e.target === nodeId + ); + + for (const edge of connectedEdges) { + const connectedNode = edge.source === nodeId ? edge.target : edge.source; + dfs(connectedNode, component); + } + }; + + for (const node of nodes) { + if (!visited.has(node.id)) { + const component: string[] = []; + dfs(node.id, component); + if (component.length > 1) { + components.push(component); + } + } + } + + return components; + } + + private exportToJson(graph: LinkGraph): string { + return JSON.stringify(graph, null, 2); + } + + private exportToMermaid(graph: LinkGraph): string { + const lines = ['graph TD']; + + // Add nodes with labels + for (const node of graph.nodes) { + const shape = this.getMermaidNodeShape(node); + const label = node.label.replace(/[[\]]/g, ''); // Remove brackets + lines.push(' ' + node.id + shape[0] + label + shape[1]); + } + + // Add edges + for (const edge of graph.edges) { + const arrow = this.getMermaidArrow(edge); + lines.push(' ' + edge.source + ' ' + arrow + ' ' + edge.target); + } + + return lines.join('\n'); + } + + private getMermaidNodeShape(node: GraphNode): [string, string] { + switch (node.type) { + case 'markdown': + return ['[', ']']; + case 'external': + return ['((', '))']; + case 'image': + return ['([', '])']; + case 'directory': + return ['{', '}']; + default: + return ['[', ']']; + } + } + + private getMermaidArrow(edge: GraphEdge): string { + switch (edge.type) { + case 'external': + return '-..->'; + case 'image': + return '==->'; + default: + return '-->'; + } + } + + private exportToDot(graph: LinkGraph): string { + const lines = ['digraph LinkGraph {']; + lines.push(' node [shape=box];'); + + // Add nodes + for (const node of graph.nodes) { + const style = this.getDotNodeStyle(node); + lines.push(' "' + node.id + '" [label="' + node.label + '"' + style + '];'); + } + + // Add edges + for (const edge of graph.edges) { + const style = this.getDotEdgeStyle(edge); + lines.push(' "' + edge.source + '" -> "' + edge.target + '"' + style + ';'); + } + + lines.push('}'); + return lines.join('\n'); + } + + private getDotNodeStyle(node: GraphNode): string { + const styles = []; + + if (node.properties.isHub) { + styles.push('color=red'); + } + + if (node.properties.isOrphan) { + styles.push('color=gray'); + } + + switch (node.type) { + case 'external': + styles.push('shape=ellipse'); + break; + case 'image': + styles.push('shape=diamond'); + break; + } + + return styles.length > 0 ? ', ' + styles.join(', ') : ''; + } + + private getDotEdgeStyle(edge: GraphEdge): string { + const styles = []; + + switch (edge.type) { + case 'external': + styles.push('style=dashed'); + break; + case 'image': + styles.push('color=blue'); + break; + } + + return styles.length > 0 ? ' [' + styles.join(', ') + ']' : ''; + } + + private exportToHtml(graph: LinkGraph): string { + const graphDataJson = JSON.stringify(graph, null, 2); + + return ` + + + Link Graph Visualization + + + + +

Link Graph Visualization

+
+ + +`; + } +} \ No newline at end of file diff --git a/src/generated/ajv-validators.ts b/src/generated/ajv-validators.ts index 60eb2df..59cb9ae 100644 --- a/src/generated/ajv-validators.ts +++ b/src/generated/ajv-validators.ts @@ -1,431 +1,447 @@ /** * Auto-generated AJV validators for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ import Ajv from 'ajv'; -const ajv = new Ajv({ - allErrors: true, +const ajv = new Ajv({ + allErrors: true, verbose: true, - strict: false, + strict: false }); // Schema definitions export const schemas = { - $schema: 'http://json-schema.org/draft-07/schema#', - title: 'markmv API Schemas', - description: 'Auto-generated schemas for markmv methods with @group annotations', - definitions: { - moveFile: { - title: 'moveFile', - description: 'Move a single markdown file and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "markmv API Schemas", + "description": "Auto-generated schemas for markmv methods with @group annotations", + "definitions": { + "moveFile": { + "title": "moveFile", + "description": "Move a single markdown file and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - destinationPath: { - type: 'string', - description: 'Destination file path', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [ - 'markmv move old.md new.md', - 'markmv move docs/old.md archive/renamed.md --dry-run', - ], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move old.md new.md", + "markmv move docs/old.md archive/renamed.md --dry-run" + ] }, - moveFiles: { - title: 'moveFiles', - description: 'Move multiple markdown files and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', + "moveFiles": { + "title": "moveFiles", + "description": "Move multiple markdown files and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" }, + "destination": { + "type": "string" + } }, - required: ['source', 'destination'], - additionalProperties: false, - }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, + "required": [ + "moves" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': ['markmv move-files --batch file1.md:new1.md file2.md:new2.md'], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move-files --batch file1.md:new1.md file2.md:new2.md" + ] }, - validateOperation: { - title: 'validateOperation', - description: 'Validate the result of a previous operation for broken links', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', + "validateOperation": { + "title": "validateOperation", + "description": "Validate the result of a previous operation for broken links", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, + "required": [ + "result" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', + "output": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "brokenLinks": { + "type": "number" }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [] }, - testAutoExposure: { - title: 'testAutoExposure', - description: 'Test function to demonstrate auto-exposure pattern', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "testAutoExposure": { + "title": "testAutoExposure", + "description": "Test function to demonstrate auto-exposure pattern", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, + "required": [ + "input" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - message: { - type: 'string', + "output": { + "type": "object", + "properties": { + "message": { + "type": "string" }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', + "timestamp": { + "type": "string" }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Testing', - 'x-examples': ['markmv test "Hello World"'], - }, - }, + "additionalProperties": false, + "x-group": "Testing", + "x-examples": [ + "markmv test \"Hello World\"" + ] + } + } }; // Compiled validators export const validators = { moveFile: { input: ajv.compile(schemas.definitions.moveFile.properties.input), - output: ajv.compile(schemas.definitions.moveFile.properties.output), + output: ajv.compile(schemas.definitions.moveFile.properties.output) }, moveFiles: { input: ajv.compile(schemas.definitions.moveFiles.properties.input), - output: ajv.compile(schemas.definitions.moveFiles.properties.output), + output: ajv.compile(schemas.definitions.moveFiles.properties.output) }, validateOperation: { input: ajv.compile(schemas.definitions.validateOperation.properties.input), - output: ajv.compile(schemas.definitions.validateOperation.properties.output), + output: ajv.compile(schemas.definitions.validateOperation.properties.output) }, testAutoExposure: { input: ajv.compile(schemas.definitions.testAutoExposure.properties.input), - output: ajv.compile(schemas.definitions.testAutoExposure.properties.output), - }, + output: ajv.compile(schemas.definitions.testAutoExposure.properties.output) + } }; -/** Validate input for a specific method */ -export function validateInput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate input for a specific method + */ +export function validateInput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.input; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Validate output for a specific method */ -export function validateOutput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate output for a specific method + */ +export function validateOutput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.output; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Get list of available methods */ +/** + * Get list of available methods + */ export function getAvailableMethods(): string[] { return Object.keys(validators); } diff --git a/src/generated/api-routes.ts b/src/generated/api-routes.ts index af31fbf..4bb1c6f 100644 --- a/src/generated/api-routes.ts +++ b/src/generated/api-routes.ts @@ -1,6 +1,6 @@ /** * Auto-generated REST API route definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -11,11 +11,7 @@ import type { FileOperations } from '../core/file-operations.js'; export interface ApiRoute { path: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE'; - handler: ( - req: IncomingMessage, - res: ServerResponse, - markmvInstance: FileOperations - ) => Promise; + handler: (req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations) => Promise; description: string; inputSchema: object; outputSchema: object; @@ -27,568 +23,557 @@ export const autoGeneratedApiRoutes: ApiRoute[] = [ path: '/api/move-file', method: 'POST', handler: createmoveFileHandler, - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/move-files', method: 'POST', handler: createmoveFilesHandler, - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } + }, + "required": [ + "moves" + ], + "additionalProperties": false +}, + outputSchema: { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - }, - additionalProperties: false, - }, - }, - required: ['moves'], - additionalProperties: false, - }, - outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/validate-operation', method: 'POST', handler: createvalidateOperationHandler, - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "brokenLinks": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false +} }, { path: '/api/test-auto-exposure', method: 'POST', handler: createtestAutoExposureHandler, - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, + "required": [ + "input" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - message: { - type: 'string', - }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', - }, + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false +} + } ]; // These handler functions will be created dynamically by the API server // They are placeholders for the auto-generated route definitions export async function createmoveFileHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFile', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const sourcePath = bodyObj.sourcePath; - const destinationPath = bodyObj.destinationPath; + const destinationPath = bodyObj.destinationPath; const options = bodyObj.options || {}; - - if ( - typeof sourcePath === 'string' && - typeof destinationPath === 'string' && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { - result = await markmvInstance.moveFile( - sourcePath, - destinationPath, - options as Record - ); + + if (typeof sourcePath === 'string' && typeof destinationPath === 'string' && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { + result = await markmvInstance.moveFile(sourcePath, destinationPath, options as Record); } else { throw new Error('Invalid parameters for moveFile'); } - + // Validate output const outputValidation = validateOutput('moveFile', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFile:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createmoveFilesHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFiles', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const moves = bodyObj.moves; const options = bodyObj.options || {}; - - if ( - Array.isArray(moves) && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { + + if (Array.isArray(moves) && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { result = await markmvInstance.moveFiles(moves, options as Record); } else { throw new Error('Invalid parameters for moveFiles'); } - + // Validate output const outputValidation = validateOutput('moveFiles', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFiles:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createvalidateOperationHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('validateOperation', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const operationResult = bodyObj.result; - - if ( - typeof operationResult === 'object' && - operationResult !== null && - !Array.isArray(operationResult) - ) { + + if (typeof operationResult === 'object' && operationResult !== null && !Array.isArray(operationResult)) { // Type guard to ensure operationResult has required OperationResult properties const opResult = operationResult as Record; - if ( - typeof opResult.success === 'boolean' && - Array.isArray(opResult.modifiedFiles) && - Array.isArray(opResult.createdFiles) && - Array.isArray(opResult.deletedFiles) && - Array.isArray(opResult.errors) && - Array.isArray(opResult.warnings) && - Array.isArray(opResult.changes) - ) { - result = await markmvInstance.validateOperation( - opResult as unknown as import('../types/operations.js').OperationResult - ); + if (typeof opResult.success === 'boolean' && + Array.isArray(opResult.modifiedFiles) && + Array.isArray(opResult.createdFiles) && + Array.isArray(opResult.deletedFiles) && + Array.isArray(opResult.errors) && + Array.isArray(opResult.warnings) && + Array.isArray(opResult.changes)) { + result = await markmvInstance.validateOperation(opResult as unknown as import('../types/operations.js').OperationResult); } else { throw new Error('Invalid OperationResult structure'); } } else { throw new Error('Invalid parameters for validateOperation'); } - + // Validate output const outputValidation = validateOutput('validateOperation', result); if (!outputValidation.valid) { console.warn('Output validation failed for validateOperation:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createtestAutoExposureHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, _markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('testAutoExposure', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const input = bodyObj.input; - + if (typeof input === 'string') { // Import and call the standalone function const { testAutoExposure } = await import('../index.js'); @@ -596,32 +581,32 @@ export async function createtestAutoExposureHandler( } else { throw new Error('Invalid parameters for testAutoExposure'); } - + // Validate output const outputValidation = validateOutput('testAutoExposure', result); if (!outputValidation.valid) { console.warn('Output validation failed for testAutoExposure:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } -/** Helper functions */ +/** + * Helper functions + */ async function parseRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; - req.on('data', (chunk) => { + req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { @@ -635,12 +620,16 @@ async function parseRequestBody(req: IncomingMessage): Promise { }); } -/** Get API route by path */ +/** + * Get API route by path + */ export function getApiRouteByPath(path: string): ApiRoute | undefined { - return autoGeneratedApiRoutes.find((route) => route.path === path); + return autoGeneratedApiRoutes.find(route => route.path === path); } -/** Get all API route paths */ +/** + * Get all API route paths + */ export function getApiRoutePaths(): string[] { - return autoGeneratedApiRoutes.map((route) => route.path); + return autoGeneratedApiRoutes.map(route => route.path); } diff --git a/src/generated/mcp-tools.ts b/src/generated/mcp-tools.ts index fb176e5..764ff58 100644 --- a/src/generated/mcp-tools.ts +++ b/src/generated/mcp-tools.ts @@ -1,6 +1,6 @@ /** * Auto-generated MCP tool definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -10,184 +10,201 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; export const autoGeneratedMcpTools: Tool[] = [ { name: 'move_file', - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" + }, + "destinationPath": { + "type": "string", + "description": "Destination file path" + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +} }, { name: 'move_files', - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, - }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, - }, + "required": [ + "moves" + ], + "additionalProperties": false +} }, { name: 'validate_operation', - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +} }, { name: 'test_auto_exposure', - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, - }, + "required": [ + "input" + ], + "additionalProperties": false +} + } ]; -/** Get MCP tool by name */ +/** + * Get MCP tool by name + */ export function getMcpToolByName(name: string): Tool | undefined { - return autoGeneratedMcpTools.find((tool) => tool.name === name); + return autoGeneratedMcpTools.find(tool => tool.name === name); } -/** Get all MCP tool names */ +/** + * Get all MCP tool names + */ export function getMcpToolNames(): string[] { - return autoGeneratedMcpTools.map((tool) => tool.name); + return autoGeneratedMcpTools.map(tool => tool.name); } + diff --git a/src/index.ts b/src/index.ts index 6422332..c353e4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export { LinkParser } from './core/link-parser.js'; export { LinkRefactorer } from './core/link-refactorer.js'; export { LinkValidator } from './core/link-validator.js'; export { LinkConverter } from './core/link-converter.js'; +export { LinkGraphGenerator } from './core/link-graph-generator.js'; export { DependencyGraph } from './core/dependency-graph.js'; export { ContentJoiner } from './core/content-joiner.js'; export { ContentSplitter } from './core/content-splitter.js'; @@ -80,6 +81,7 @@ export { // Command functions for programmatic access export { convertCommand } from './commands/convert.js'; +export { graphCommand, generateGraph } from './commands/graph.js'; export { indexCommand } from './commands/index.js'; export { tocCommand, generateToc as generateTocForFiles } from './commands/toc.js'; export { validateCommand, validateLinks } from './commands/validate.js'; @@ -105,6 +107,18 @@ export type { BarrelOperationOptions, } from './types/operations.js'; +export type { + GraphOperationOptions, + GraphCliOptions, + GraphResult, +} from './commands/graph.js'; +export type { + LinkGraphOptions, + GraphNode, + GraphEdge, + LinkGraph, + GraphOutputFormat, +} from './core/link-graph-generator.js'; export type { IndexOptions, FileMetadata, IndexableFile } from './commands/index.js'; export type { TocOperationOptions, TocCliOptions, TocResult } from './commands/toc.js'; export type { @@ -327,6 +341,52 @@ export async function generateIndex( */ export const generateBarrel = generateIndex; +/** + * Generate interactive link graphs from markdown file relationships + * + * @example + * Basic graph generation + * ```typescript + * import { generateLinkGraph } from 'markmv'; + * + * const result = await generateLinkGraph(['docs/**\/*.md'], { + * format: 'mermaid', + * includeExternal: false + * }); + * + * console.log('Generated Mermaid diagram:'); + * console.log(result.content); + * ``` + * + * @example + * Interactive HTML visualization + * ```typescript + * import { generateLinkGraph } from 'markmv'; + * + * const result = await generateLinkGraph(['**\/*.md'], { + * format: 'html', + * output: 'visualization.html', + * includeImages: true + * }); + * + * console.log('Interactive graph saved to: ' + result.outputFile); + * ``` + * + * @param patterns - File patterns to analyze (supports globs) + * @param options - Graph generation options + * + * @returns Promise resolving to graph generation result + * + * @group Commands + */ +export async function generateLinkGraph( + patterns: string[], + options: import('./commands/graph.js').GraphOperationOptions = { format: 'json' } +): Promise { + const { generateGraph } = await import('./commands/graph.js'); + return generateGraph(patterns, options); +} + /** * Test function to demonstrate auto-exposure pattern *