diff --git a/.gitignore b/.gitignore index a352482..8be424c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ dist/ coverage/ +# Generated reports +report/ + # Dependencies node_modules/ diff --git a/package-lock.json b/package-lock.json index 895497d..3c6a340 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "typescript": "5.8.3" }, "bin": { - "codeviz": "dist/cli.js" + "codeviz": "dist/cli.cjs" }, "devDependencies": { "@eslint/js": "9.28.0", @@ -24,7 +24,7 @@ "@vitest/coverage-v8": "4.1.8", "esbuild": "0.28.0", "eslint": "9.28.0", - "jiti": "^2.7.0", + "jiti": "2.7.0", "tsup": "8.5.0", "typescript-eslint": "8.33.0", "vitest": "4.1.8" diff --git a/package.json b/package.json index 6563820..ccdcf1a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Analyze source code and generate interactive dependency graphs", "type": "module", "bin": { - "codeviz": "./dist/cli.js" + "codeviz": "./dist/cli.cjs" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/cli.ts b/src/cli.ts index 24b87c0..7f3c506 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { Command } from 'commander' import { VERSION } from './index.js' import { analyze } from './analyze.js' import { renderToTerminal } from './renderer.js' +import { generateHtmlReport } from './html-report.js' const program = new Command() @@ -11,19 +12,28 @@ program .description('Analyze source code and generate interactive dependency graphs') .version(VERSION) .argument('[directory]', 'source directory to analyze', '.') - .option('--html', 'generate an interactive HTML report (coming in Phase 2)') + .option('--html', 'generate an interactive HTML report in the report/ directory') + .option('--out-dir ', 'output directory for the HTML report', 'report') .option('--show-external', 'include external npm packages in the output') .option('--ignore ', 'comma-separated list of directory names to ignore', '') - .action(async (directory: string, options: { html?: boolean; showExternal?: boolean; ignore?: string }) => { - if (options.html) { - console.warn('--html report generation is not yet implemented (Phase 2).') - } - + .action(async ( + directory: string, + options: { html?: boolean; outDir?: string; showExternal?: boolean; ignore?: string }, + ) => { const ignore = options.ignore ? options.ignore.split(',').map((d) => d.trim()).filter(Boolean) : [] try { const graph = await analyze(directory, { ignore }) - renderToTerminal(graph, { showExternal: options.showExternal }) + + if (options.html) { + const { htmlFile, jsonFile } = await generateHtmlReport(graph, { outDir: options.outDir }) + console.log(`\n HTML report generated:`) + console.log(` ${htmlFile}`) + console.log(` ${jsonFile}`) + console.log(`\n Open in browser: file://${htmlFile}\n`) + } else { + renderToTerminal(graph, { showExternal: options.showExternal }) + } } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\nError: ${message}\n`) diff --git a/src/html-report.ts b/src/html-report.ts new file mode 100644 index 0000000..94cf7c3 --- /dev/null +++ b/src/html-report.ts @@ -0,0 +1,617 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve, join } from 'node:path' +import type { DependencyGraph } from './graph.js' + +export interface HtmlReportOptions { + /** Output directory. Defaults to `./report` relative to cwd */ + outDir?: string + /** Title shown in the HTML page */ + title?: string +} + +export interface HtmlReportResult { + htmlFile: string + jsonFile: string +} + +// --------------------------------------------------------------------------- +// Serialise the graph into a plain JSON structure for the browser +// --------------------------------------------------------------------------- + +interface SerializedNode { + id: string + label: string + external: boolean +} + +interface SerializedEdge { + from: string + to: string + specifier: string +} + +interface SerializedGraph { + nodes: SerializedNode[] + edges: SerializedEdge[] + isolatedNodes: string[] + cycles: string[][] +} + +function serialize(graph: DependencyGraph): SerializedGraph { + return { + nodes: [...graph.nodes.values()].map((n) => ({ + id: n.id, + label: n.label, + external: n.external, + })), + edges: graph.edges.map((e) => ({ + from: e.from, + to: e.to, + specifier: e.specifier, + })), + isolatedNodes: graph.isolatedNodes, + cycles: graph.cycles, + } +} + +// --------------------------------------------------------------------------- +// HTML template +// --------------------------------------------------------------------------- + +function buildHtml(title: string, graphJson: string): string { + return /* html */ ` + + + + + ${escHtml(title)} + + + + + + +
+

codeviz — Dependency Graph

+ + 0 files + 0 deps + 0 isolated + 0 cycles +
+ + +
+
+ +
+ +
+

Summary

+
Files scanned
+
Internal deps
+
Isolated files
+
Circular deps
+
+ + +
+

+
+

Imports

+
+
+
+

Imported by

+
+
+
+ + + + + +
+
+ +
+ + + + +`; +} + +function escHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>') +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generates a self-contained HTML report and a companion `graph.json` file + * inside `outDir`. Returns the paths of the created files. + */ +export async function generateHtmlReport( + graph: DependencyGraph, + options: HtmlReportOptions = {}, +): Promise { + const outDir = resolve(options.outDir ?? 'report') + const title = options.title ?? 'codeviz — Dependency Graph' + + await mkdir(outDir, { recursive: true }) + + const serialized = serialize(graph) + const graphJson = JSON.stringify(serialized, null, 2) + + const jsonFile = join(outDir, 'graph.json') + await writeFile(jsonFile, graphJson, 'utf8') + + const html = buildHtml(title, graphJson) + const htmlFile = join(outDir, 'index.html') + await writeFile(htmlFile, html, 'utf8') + + return { htmlFile, jsonFile } +} diff --git a/src/index.ts b/src/index.ts index 0638c19..e10bfed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { parseImports } from './parser.js' export { extractDependencies } from './extractor.js' export { buildGraph } from './graph.js' export { renderToTerminal } from './renderer.js' +export { generateHtmlReport } from './html-report.js' export type { AnalyzeOptions } from './analyze.js' export type { ScanOptions } from './scanner.js' @@ -13,3 +14,4 @@ export type { ImportRecord } from './parser.js' export type { Dependency } from './extractor.js' export type { DependencyGraph, GraphNode, GraphEdge } from './graph.js' export type { RenderOptions } from './renderer.js' +export type { HtmlReportOptions, HtmlReportResult } from './html-report.js' diff --git a/tsup.config.ts b/tsup.config.ts index 426e183..7fcfc48 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,18 +1,19 @@ import { defineConfig } from 'tsup' export default defineConfig([ - // CLI entry — shebang injected via banner + // CLI entry — built as CJS with .cjs extension so the shebang works + // even when package.json has "type": "module" { entry: { cli: 'src/cli.ts' }, - format: ['esm'], + format: ['cjs'], target: 'node18', outDir: 'dist', clean: false, dts: false, sourcemap: true, splitting: false, - shims: false, - banner: { js: '#!/usr/bin/env node' }, + shims: true, + outExtension: () => ({ js: '.cjs' }), }, // Library entry — no shebang {