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 —
+
+
+
+
+
+
+
+
⊗ Circular Dependencies
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+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
{