If you'd told me that one of my side-projects would involve publishing my own command-line tool to npm, I would've been skeptical. My journey into programming has been a series of small steps, and the idea of creating and distributing a developer tool felt like a giant leap.
Then I built Code Cartographer, and everything changed. I started with a simple problem: I couldn't easily visualize the web of imports in my projects. By the time I was done, I had not only solved my problem but had also accidentally climbed a mountain of new concepts.
It was a small climb, but it taught me three fundamental lessons.
1. Your MVP Can Be a Single HTML File
My biggest initial hurdle wasn't technical; it was conceptual. How do you even begin to visualize a complex network of code? My mind immediately jumped to complex solutions involving GUIs, canvas renderings, and maybe even WebGL.
Then, I took a step back. What's the absolute simplest thing that could work? The answer was surprisingly low-tech: a single, self-contained HTML file.
Instead of building a complex application, I decided to have my tool generate an HTML document containing the visualization data and the rendering logic. The browser is an incredibly powerful and universal platform. Why not use it? This approach meant I could leverage existing JavaScript libraries for the graphing (like D3.js) and not worry about building a user interface from scratch.
This is the core idea behind the html-generator.ts file in my project. It takes the dependency data and injects it into a template, creating a ready-to-use visualization.
// A simplified view of the HTML generator
import { writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
export function generateHtml(data: any) {
// Read an HTML template file
const template = readFileSync(join(__dirname, 'template.html'), 'utf-8');
// Inject the project's dependency data into the template
const finalHtml = template.replace(
'/* DATA_PLACEHOLDER */',
`window.graphData = ${JSON.stringify(data)};`
);
// Write the final HTML file
writeFileSync('code-map.html', finalHtml);
}To top it off, I used the open package to automatically pop open the user's browser with the generated file. It’s a small touch, but it makes the tool feel responsive and intuitive. Sometimes the best solution is the one that piggybacks on the powerful tools you already have.
2. You Can Use Babel to Read and Understand Code
This was the technical crux of the project and my biggest learning. To find all the import statements, I couldn't just use regular expressions—code is too complex for that. I needed to understand it on a structural level. The answer was to use an Abstract Syntax Tree (AST).
An AST is a tree representation of source code. I learned that the Babel parser, the same tool that powers a huge part of the JavaScript ecosystem, can be used programmatically to generate an AST from a string of code.
Once I had the tree, I could traverse it to find exactly what I was looking for. My analyzer.ts file does exactly this. It reads a file, parses it with @babel/parser, and then uses @babel/traverse to visit every node in the tree, looking for import and export declarations.
// src/analyzer.ts
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
// ...
// Inside the analyzeFile method:
const content = readFileSync(filePath, 'utf-8');
const ast = this.parseCode(content, filePath); // Uses babel.parse
// The magic happens here:
traverse(ast, {
// Visit every ES6 import statement
ImportDeclaration: (path) => {
// path.node contains all the info about the import
const importSource = path.node.source.value;
analysis.imports.push({ source: importSource, /* ...other data */ });
},
// Also handle commonjs `require` calls
CallExpression: (path) => {
if (path.node.callee.name === 'require') {
const importSource = path.node.arguments[0].value;
analysis.imports.push({ source: importSource, /* ...other data */ });
}
},
// And find all the exports
ExportNamedDeclaration: (path) => { /* ... */ },
ExportDefaultDeclaration: (path) => { /* ... */ },
});Learning this felt like gaining a superpower. I wasn't just writing code anymore; I was writing code that could read and understand other code. This opened my eyes to the world of static analysis and the universe of developer tooling built on this principle.
3. npm Is More Than Just a Package Manager
For the longest time, I thought of npm purely as a place to download packages from. npm install was a reflex, but npm publish was a mystery.
As I polished my script, I realized it was becoming a genuinely useful command-line tool. But how could I run it easily in any project, without pointing to the script's location with a long, ugly path? And how could I share it with others?
This led me down the rabbit hole of publishing to the npm registry. It turns out, it's a lot like pushing code to GitHub, but for packages. The key was in the package.json file. By adding a bin field, I could tell npm that my package contained an executable file.
// package.json
{
"name": "code-cartographer",
"version": "1.0.0",
"description": "A CLI tool that visualizes code repository structures...",
"main": "dist/index.js",
"bin": {
"cartographer": "dist/cli.js"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}When someone installs my package globally (npm install -g code-cartographer), this configuration creates a command called cartographer on their system. Even better, anyone can run it directly without installing using npx code-cartographer my-project/.
The prepublishOnly script ensures my TypeScript code is always compiled into JavaScript before it gets uploaded. This whole process demystified package distribution for me. It's a powerful system for sharing not just libraries, but standalone tools that can help other developers.