bunx-ray 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PUBLISHING.md ADDED
@@ -0,0 +1,87 @@
1
+ # Publishing `bunx-ray` to npm
2
+
3
+ > Quick reference for future releases. Assumes you already ran `npm login` (or `npm adduser`).
4
+
5
+ ---
6
+
7
+ ## 1. Build before publishing
8
+
9
+ ```bash
10
+ npm run build # compiles TypeScript to dist/
11
+ ```
12
+
13
+ `package.json` already has:
14
+
15
+ ```json
16
+ "prepublishOnly": "npm run build",
17
+ "files": ["dist"]
18
+ ```
19
+
20
+ So running `npm publish` will automatically rebuild and only ship the compiled JS.
21
+
22
+ ---
23
+
24
+ ## 2. Choose a package name & scope
25
+
26
+ | Package name value | Default visibility | Can be private? |
27
+ | ------------------------- | ------------------ | --------------- |
28
+ | `"name": "bunx-ray"` | Public | **No** |
29
+ | `"name": "@you/bunx-ray"` | Private | Yes |
30
+
31
+ _Change the `name` field if needed._
32
+
33
+ ---
34
+
35
+ ## 3. Publish commands
36
+
37
+ ### A) Un-scoped, public (most common)
38
+
39
+ ```bash
40
+ npm version patch # bump 0.1.x → 0.1.(x+1)
41
+ npm publish # publishes publicly
42
+ ```
43
+
44
+ ### B) Scoped & public
45
+
46
+ ```bash
47
+ npm version minor # or patch / major
48
+ npm publish --access public
49
+ ```
50
+
51
+ ### C) Scoped & private (default for scoped)
52
+
53
+ ```bash
54
+ npm version patch
55
+ npm publish --access restricted # or simply: npm publish
56
+ ```
57
+
58
+ ---
59
+
60
+ ## 4. Extras
61
+
62
+ - **Dry-run** – see what would be published:
63
+ ```bash
64
+ npm publish --dry-run
65
+ ```
66
+ - **Dist-tags** – publish a prerelease:
67
+ ```bash
68
+ npm publish --tag next # install with npm i bunx-ray@next
69
+ ```
70
+ - **Private registry**:
71
+ ```bash
72
+ npm publish --registry https://registry.my-company.com/
73
+ ```
74
+
75
+ ---
76
+
77
+ ## 5. Verify
78
+
79
+ ```bash
80
+ npm view bunx-ray version # expect the new version
81
+ npm i -g bunx-ray # test global install
82
+ bunx-ray --help # ensure binary runs
83
+ ```
84
+
85
+ ---
86
+
87
+ Happy shipping! 🚀
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # bunx-ray
2
+
3
+ **ASCII heat-map bundle viewer** – inspect JavaScript bundle composition right in your terminal (CI-friendly, SSH-friendly, browser-free).
4
+
5
+ ---
6
+
7
+ ## Install & run
8
+
9
+ ```bash
10
+ # global (recommended)
11
+ npm install -g bunx-ray
12
+
13
+ # or one-off
14
+ npx bunx-ray <stats.json>
15
+ ```
16
+
17
+ ## CLI
18
+
19
+ ```
20
+ bunx-ray [stats.json] [flags]
21
+
22
+ Flags
23
+ --webpack Treat input as Webpack stats (default auto-detect)
24
+ --vite Treat input as Vite / Rollup stats
25
+ --esbuild Treat input as esbuild metafile
26
+ --cols <n> Terminal columns (default 80)
27
+ --rows <n> Terminal rows (default 24)
28
+ --top <n> Show N largest modules (default 10)
29
+ --grid-only Only print grid (no legend / summary)
30
+ --no-legend Hide legend line
31
+ --no-summary Hide bundle summary
32
+ -h, --help Show help
33
+ ```
34
+
35
+ ### Quick demo (no bundler needed)
36
+
37
+ ```bash
38
+ bunx-ray $(npx bunx-ray demo) # renders a built-in sample
39
+ ```
40
+
41
+ _(`bunx-ray demo` prints a temp stats file path; handy for first-time tryout.)_
42
+
43
+ ---
44
+
45
+ ## Real-world recipes
46
+
47
+ ### Webpack ≥ 4
48
+
49
+ ```bash
50
+ npx webpack --stats-json # writes stats.json
51
+ bunx-ray stats.json # view heat-map
52
+ ```
53
+
54
+ ### Vite v5 / Rollup
55
+
56
+ ```bash
57
+ vite build --stats.writeTo stats.json
58
+ bunx-ray --vite stats.json
59
+ ```
60
+
61
+ ### esbuild
62
+
63
+ ```bash
64
+ esbuild src/index.ts --bundle --metafile=meta.json --outfile=/dev/null
65
+ bunx-ray --esbuild meta.json
66
+ ```
67
+
68
+ ---
69
+
70
+ ## TypeScript API
71
+
72
+ Install as a normal dependency and import what you need:
73
+
74
+ ```ts
75
+ import { normalizeWebpack, treemap, draw, Mod } from "bunx-ray";
76
+
77
+ const mods: Mod[] = normalizeWebpack(
78
+ JSON.parse(readFileSync("stats.json", "utf8"))
79
+ );
80
+ console.log(draw(treemap(mods, 80, 24)));
81
+ ```
82
+
83
+ All `.d.ts` files ship with the package—no extra `@types` install required.
84
+
85
+ ---
86
+
87
+ ## Why text over HTML?
88
+
89
+ - Works in CI logs, SSH sessions, Codespaces, headless Docker containers.
90
+ - Diff-friendly → fail PR when a module grows past your budget.
91
+ - Zero browser animations = instant feedback.
92
+
93
+ ---
94
+
95
+ ## Contributing / local playground
96
+
97
+ Clone the repo and:
98
+
99
+ ```bash
100
+ npm install # install dev deps
101
+ npm run build # compile TypeScript → dist/
102
+
103
+ # sample bundles
104
+ npm run sample:webpack # outputs dist-webpack/stats.json
105
+ npm run sample:vite # outputs dist-vite/stats.json
106
+ npm run sample:esbuild # outputs dist-esbuild/meta.json
107
+ ```
108
+
109
+ Run tests:
110
+
111
+ ```bash
112
+ npm test
113
+ ```
114
+
115
+ Feel free to tweak the `src-*` sample sources to watch the heat-map change.
@@ -0,0 +1,21 @@
1
+ export type Mod = {
2
+ path: string;
3
+ size: number;
4
+ };
5
+ export interface Cell {
6
+ x: number;
7
+ y: number;
8
+ w: number;
9
+ h: number;
10
+ mod: Mod;
11
+ }
12
+ export declare function treemap(mods: Mod[], W?: number, H?: number): Cell[];
13
+ export declare function draw(cells: Cell[], W?: number, H?: number): string;
14
+ export declare function normalizeWebpack(stats: any): Mod[];
15
+ export declare function normalizeVite(stats: any): Mod[];
16
+ export declare function normalizeEsbuild(meta: any): Mod[];
17
+ export declare const SIZE_THRESHOLDS: number[];
18
+ export declare function formatSize(bytes: number): string;
19
+ export declare function totalSize(mods: Mod[]): number;
20
+ export declare function topModules(mods: Mod[], n?: number): Mod[];
21
+ //# sourceMappingURL=bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../src/bundle.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,GAAG,GAAG;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,WAAW,IAAI;IACnB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,GAAG,EAAE,GAAG,CAAC;CACV;AAGD,wBAAgB,OAAO,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,SAAK,EAAE,CAAC,SAAK,GAAG,IAAI,EAAE,CAiB3D;AAED,wBAAgB,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,SAAK,EAAE,CAAC,SAAK,GAAG,MAAM,CAe1D;AAGD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,EAAE,CAalD;AAGD,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,EAAE,CAa/C;AAGD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,GAAG,GAAG,GAAG,EAAE,CAUjD;AAID,eAAO,MAAM,eAAe,UAAqC,CAAC;AAElE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIhD;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,MAAM,CAE7C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,SAAK,GAAG,GAAG,EAAE,CAErD"}
package/dist/bundle.js ADDED
@@ -0,0 +1,94 @@
1
+ // simple slice-and-dice treemap mapped to character grid
2
+ export function treemap(mods, W = 80, H = 24) {
3
+ const cells = [];
4
+ let x = 0, y = 0, rowH = H;
5
+ const total = mods.reduce((a, m) => a + m.size, 0);
6
+ for (const m of mods) {
7
+ const frac = m.size / total;
8
+ const w = Math.max(1, Math.round((frac * W * H) / rowH));
9
+ if (x + w > W) {
10
+ y += rowH;
11
+ x = 0;
12
+ }
13
+ cells.push({ x, y, w, h: rowH, mod: m });
14
+ x += w;
15
+ }
16
+ return cells;
17
+ }
18
+ export function draw(cells, W = 80, H = 24) {
19
+ const grid = Array.from({ length: H }, () => Array(W).fill(' '));
20
+ const shades = ['░', '▒', '▓', '█'];
21
+ const max = Math.max(...cells.map((c) => c.mod.size));
22
+ for (const c of cells) {
23
+ const shade = shades[Math.floor((c.mod.size / max) * (shades.length - 1))];
24
+ for (let i = 0; i < c.h; i++) {
25
+ for (let j = 0; j < c.w; j++) {
26
+ if (c.y + i < H && c.x + j < W) {
27
+ grid[c.y + i][c.x + j] = shade;
28
+ }
29
+ }
30
+ }
31
+ }
32
+ return grid.map((r) => r.join('')).join('\n');
33
+ }
34
+ // Normalize Webpack stats → Mod[]
35
+ export function normalizeWebpack(stats) {
36
+ const mods = [];
37
+ if (Array.isArray(stats.modules)) {
38
+ for (const m of stats.modules) {
39
+ const size = m.size ?? m.parsedSize ?? 0;
40
+ const name = m.name ?? m.identifier ?? '';
41
+ if (size && name)
42
+ mods.push({ path: name, size });
43
+ }
44
+ }
45
+ else if (Array.isArray(stats.children)) {
46
+ for (const child of stats.children)
47
+ mods.push(...normalizeWebpack(child));
48
+ }
49
+ mods.sort((a, b) => b.size - a.size);
50
+ return mods;
51
+ }
52
+ // Vite / Rollup stats (array of outputs with modules)
53
+ export function normalizeVite(stats) {
54
+ const mods = [];
55
+ const outputs = Array.isArray(stats.output) ? stats.output : [stats.output ?? stats];
56
+ for (const out of outputs) {
57
+ if (!out || !out.modules)
58
+ continue;
59
+ const modulesObj = out.modules;
60
+ for (const [p, m] of Object.entries(modulesObj)) {
61
+ const size = m.renderedLength ?? m.renderedSize ?? m.originalLength ?? m.size ?? 0;
62
+ mods.push({ path: p, size });
63
+ }
64
+ }
65
+ mods.sort((a, b) => b.size - a.size);
66
+ return mods;
67
+ }
68
+ // esbuild metafile JSON
69
+ export function normalizeEsbuild(meta) {
70
+ const mods = [];
71
+ if (meta.inputs && typeof meta.inputs === 'object') {
72
+ for (const [p, info] of Object.entries(meta.inputs)) {
73
+ const size = info.bytes ?? 0;
74
+ mods.push({ path: p, size });
75
+ }
76
+ }
77
+ mods.sort((a, b) => b.size - a.size);
78
+ return mods;
79
+ }
80
+ // ---------------- Utilities ------------------
81
+ export const SIZE_THRESHOLDS = [10 * 1024, 50 * 1024, 100 * 1024]; // bytes
82
+ export function formatSize(bytes) {
83
+ if (bytes >= 1024 * 1024)
84
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
85
+ if (bytes >= 1024)
86
+ return (bytes / 1024).toFixed(1) + ' KB';
87
+ return bytes + ' B';
88
+ }
89
+ export function totalSize(mods) {
90
+ return mods.reduce((a, m) => a + m.size, 0);
91
+ }
92
+ export function topModules(mods, n = 10) {
93
+ return [...mods].sort((a, b) => b.size - a.size).slice(0, n);
94
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,85 @@
1
+ // ---- CLI -----------------------------------------------------------------
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { normalizeWebpack, normalizeVite, normalizeEsbuild } from './bundle.js';
7
+ import { renderReport } from './report.js';
8
+ function main() {
9
+ const program = new Command();
10
+ program
11
+ .name('bunx-ray')
12
+ .description('ASCII heat-map bundle viewer')
13
+ .argument('<stats>', 'Build stats JSON file')
14
+ .option('--webpack', 'Input is Webpack stats (default)')
15
+ .option('--vite', 'Input is Vite/Rollup stats')
16
+ .option('--esbuild', 'Input is esbuild metafile')
17
+ .option('--cols <number>', 'Terminal columns (default 80)', '80')
18
+ .option('--rows <number>', 'Terminal rows (default 24)', '24')
19
+ .option('--top <number>', 'Show N largest modules (default 10)', '10')
20
+ .option('--no-legend', 'Hide legend line')
21
+ .option('--no-summary', 'Hide summary line')
22
+ .option('--grid-only', 'Only print grid (implies --no-legend --no-summary)')
23
+ .option('--demo', 'Render built-in demo heat-map')
24
+ .parse(process.argv);
25
+ const opts = program.opts();
26
+ const file = program.args[0];
27
+ const cols = Number(opts.cols);
28
+ const rows = Number(opts.rows);
29
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
30
+ console.error(chalk.red('Error: --cols and --rows must be numbers'));
31
+ process.exit(1);
32
+ }
33
+ const raw = fs.readFileSync(path.resolve(process.cwd(), file), 'utf8');
34
+ let stats;
35
+ try {
36
+ stats = JSON.parse(raw);
37
+ }
38
+ catch (e) {
39
+ console.error(chalk.red(`Failed to parse JSON from ${file}`));
40
+ process.exit(1);
41
+ }
42
+ // Choose adapter based on flags or auto-detect.
43
+ let mods = [];
44
+ if (opts.webpack) {
45
+ mods = normalizeWebpack(stats);
46
+ }
47
+ else if (opts.vite) {
48
+ mods = normalizeVite(stats);
49
+ }
50
+ else if (opts.esbuild) {
51
+ mods = normalizeEsbuild(stats);
52
+ }
53
+ else {
54
+ // auto-detect simple heuristics
55
+ if (stats.inputs && stats.outputs)
56
+ mods = normalizeEsbuild(stats);
57
+ else if (stats.modules || stats.children)
58
+ mods = normalizeWebpack(stats);
59
+ else if (stats.output)
60
+ mods = normalizeVite(stats);
61
+ else {
62
+ console.error(chalk.red('Unable to detect stats format; please pass --webpack | --vite | --esbuild'));
63
+ process.exit(1);
64
+ }
65
+ }
66
+ if (mods.length === 0) {
67
+ console.error(chalk.yellow('No modules found in stats file.'));
68
+ process.exit(1);
69
+ }
70
+ const report = renderReport(mods, {
71
+ cols,
72
+ rows,
73
+ top: Number(opts.top ?? 10),
74
+ legend: opts.legend !== false && !opts.gridOnly,
75
+ summary: opts.summary !== false && !opts.gridOnly,
76
+ color: true,
77
+ });
78
+ if (report.legendLine)
79
+ console.log(report.legendLine);
80
+ if (report.summaryLine)
81
+ console.log(report.summaryLine);
82
+ console.log('\n' + report.grid + '\n');
83
+ report.tableLines.forEach((l) => console.log(l));
84
+ }
85
+ main();
@@ -0,0 +1,3 @@
1
+ export type { Mod, Cell } from './bundle.js';
2
+ export { normalizeWebpack, normalizeVite, normalizeEsbuild, treemap, draw, formatSize, totalSize, topModules, } from './bundle.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,OAAO,EACP,IAAI,EACJ,UAAU,EACV,SAAS,EACT,UAAU,GACX,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { normalizeWebpack, normalizeVite, normalizeEsbuild, treemap, draw, formatSize, totalSize, topModules, } from './bundle.js';
@@ -0,0 +1,17 @@
1
+ import { Mod } from './bundle.js';
2
+ export interface ReportOptions {
3
+ cols: number;
4
+ rows: number;
5
+ top: number;
6
+ legend: boolean;
7
+ summary: boolean;
8
+ color: boolean;
9
+ }
10
+ export interface RenderedReport {
11
+ legendLine?: string;
12
+ summaryLine?: string;
13
+ grid: string;
14
+ tableLines: string[];
15
+ }
16
+ export declare function renderReport(mods: Mod[], opts: ReportOptions): RenderedReport;
17
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAoD,MAAM,aAAa,CAAC;AAEpF,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;CAChB;AAaD,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,aAAa,GAAG,cAAc,CAsC7E"}
package/dist/report.js ADDED
@@ -0,0 +1,43 @@
1
+ import { treemap, draw, formatSize, totalSize, topModules } from './bundle.js';
2
+ const SHADES = ['░', '▒', '▓', '█'];
3
+ function calcThresholds(max) {
4
+ return [0.25, 0.5, 0.75].map((p) => max * p);
5
+ }
6
+ function shadeForSize(size, max) {
7
+ const idx = Math.floor((size / max) * (SHADES.length - 1));
8
+ return SHADES[idx];
9
+ }
10
+ export function renderReport(mods, opts) {
11
+ const { cols, rows, top, legend, summary } = opts;
12
+ const max = Math.max(...mods.map((m) => m.size));
13
+ const thresholds = calcThresholds(max);
14
+ // grid
15
+ const grid = draw(treemap(mods, cols, rows), cols, rows);
16
+ // legend
17
+ let legendLine;
18
+ if (legend) {
19
+ legendLine =
20
+ 'Legend ' +
21
+ [
22
+ `${SHADES[3]} >${formatSize(thresholds[2])}`,
23
+ `${SHADES[2]} ${formatSize(thresholds[1])}-${formatSize(thresholds[2])}`,
24
+ `${SHADES[1]} ${formatSize(thresholds[0])}-${formatSize(thresholds[1])}`,
25
+ `${SHADES[0]} <${formatSize(thresholds[0])}`,
26
+ ].join(' ');
27
+ }
28
+ // summary
29
+ let summaryLine;
30
+ if (summary) {
31
+ summaryLine = `Total bundle: ${formatSize(totalSize(mods))} | modules: ${mods.length}`;
32
+ }
33
+ // table
34
+ const list = topModules(mods, top);
35
+ const tableLines = [`Top ${list.length} modules`];
36
+ list.forEach((m, idx) => {
37
+ const shade = shadeForSize(m.size, max);
38
+ const pct = ((m.size / totalSize(mods)) * 100).toFixed(1).padStart(4);
39
+ const name = m.path.length > 28 ? '…' + m.path.slice(-27) : m.path.padEnd(28);
40
+ tableLines.push(`${(idx + 1).toString().padStart(2)} ${shade} ${name} ${formatSize(m.size).padStart(8)} (${pct}%)`);
41
+ });
42
+ return { legendLine, summaryLine, grid, tableLines };
43
+ }
package/meta.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "inputs": {
3
+ "src-esbuild/math.ts": {
4
+ "bytes": 60,
5
+ "imports": [],
6
+ "format": "esm"
7
+ },
8
+ "src-esbuild/index.ts": {
9
+ "bytes": 71,
10
+ "imports": [
11
+ {
12
+ "path": "src-esbuild/math.ts",
13
+ "kind": "import-statement",
14
+ "original": "./math"
15
+ }
16
+ ],
17
+ "format": "esm"
18
+ }
19
+ },
20
+ "outputs": {
21
+ "../../../../../dev/null": {
22
+ "imports": [],
23
+ "exports": [],
24
+ "entryPoint": "src-esbuild/index.ts",
25
+ "inputs": {
26
+ "src-esbuild/math.ts": {
27
+ "bytesInOutput": 29
28
+ },
29
+ "src-esbuild/index.ts": {
30
+ "bytesInOutput": 43
31
+ }
32
+ },
33
+ "bytes": 153
34
+ }
35
+ }
36
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "bunx-ray",
3
+ "version": "0.2.0",
4
+ "description": "ASCII heat-map bundle viewer",
5
+ "type": "module",
6
+ "bin": {
7
+ "bunx-ray": "dist/cli.js"
8
+ },
9
+ "types": "dist/index.d.ts",
10
+ "scripts": {
11
+ "prepublishOnly": "npm run build",
12
+ "build": "tsc",
13
+ "start": "node dist/cli.js",
14
+ "test": "npm run build && vitest run",
15
+ "sample:webpack": "webpack --config webpack.sample.js --mode production --json > dist-webpack/stats.json",
16
+ "sample:vite": "vite build --config vite.sample.js",
17
+ "sample:esbuild": "esbuild src-esbuild/index.ts --bundle --outfile=dist-esbuild/bundle.js --metafile=dist-esbuild/meta.json"
18
+ },
19
+ "dependencies": {
20
+ "chalk": "^5.3.0",
21
+ "commander": "^12.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.3.0",
25
+ "@types/node": "^20.8.10",
26
+ "vitest": "^3.0.0",
27
+ "execa": "^8.0.0",
28
+ "webpack": "^5.91.0",
29
+ "webpack-cli": "^5.1.4",
30
+ "vite": "^5.0.0",
31
+ "rollup-plugin-analyzer": "^4.0.0",
32
+ "esbuild": "^0.21.0"
33
+ }
34
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,115 @@
1
+ // Core functions for bunx-ray MVP
2
+ import chalk from 'chalk';
3
+
4
+ export type Mod = {
5
+ path: string;
6
+ size: number; // bytes
7
+ };
8
+
9
+ export interface Cell {
10
+ x: number;
11
+ y: number;
12
+ w: number;
13
+ h: number;
14
+ mod: Mod;
15
+ }
16
+
17
+ // simple slice-and-dice treemap mapped to character grid
18
+ export function treemap(mods: Mod[], W = 80, H = 24): Cell[] {
19
+ const cells: Cell[] = [];
20
+ let x = 0,
21
+ y = 0,
22
+ rowH = H;
23
+ const total = mods.reduce((a, m) => a + m.size, 0);
24
+ for (const m of mods) {
25
+ const frac = m.size / total;
26
+ const w = Math.max(1, Math.round((frac * W * H) / rowH));
27
+ if (x + w > W) {
28
+ y += rowH;
29
+ x = 0;
30
+ }
31
+ cells.push({ x, y, w, h: rowH, mod: m });
32
+ x += w;
33
+ }
34
+ return cells;
35
+ }
36
+
37
+ export function draw(cells: Cell[], W = 80, H = 24): string {
38
+ const grid: string[][] = Array.from({ length: H }, () => Array(W).fill(' '));
39
+ const shades = ['░', '▒', '▓', '█'];
40
+ const max = Math.max(...cells.map((c) => c.mod.size));
41
+ for (const c of cells) {
42
+ const shade = shades[Math.floor((c.mod.size / max) * (shades.length - 1))];
43
+ for (let i = 0; i < c.h; i++) {
44
+ for (let j = 0; j < c.w; j++) {
45
+ if (c.y + i < H && c.x + j < W) {
46
+ grid[c.y + i][c.x + j] = shade;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return grid.map((r) => r.join('')).join('\n');
52
+ }
53
+
54
+ // Normalize Webpack stats → Mod[]
55
+ export function normalizeWebpack(stats: any): Mod[] {
56
+ const mods: Mod[] = [];
57
+ if (Array.isArray(stats.modules)) {
58
+ for (const m of stats.modules) {
59
+ const size = m.size ?? m.parsedSize ?? 0;
60
+ const name = m.name ?? m.identifier ?? '';
61
+ if (size && name) mods.push({ path: name, size });
62
+ }
63
+ } else if (Array.isArray(stats.children)) {
64
+ for (const child of stats.children) mods.push(...normalizeWebpack(child));
65
+ }
66
+ mods.sort((a, b) => b.size - a.size);
67
+ return mods;
68
+ }
69
+
70
+ // Vite / Rollup stats (array of outputs with modules)
71
+ export function normalizeVite(stats: any): Mod[] {
72
+ const mods: Mod[] = [];
73
+ const outputs = Array.isArray(stats.output) ? stats.output : [stats.output ?? stats];
74
+ for (const out of outputs) {
75
+ if (!out || !out.modules) continue;
76
+ const modulesObj = out.modules;
77
+ for (const [p, m] of Object.entries<any>(modulesObj)) {
78
+ const size = (m as any).renderedLength ?? (m as any).renderedSize ?? (m as any).originalLength ?? (m as any).size ?? 0;
79
+ mods.push({ path: p, size });
80
+ }
81
+ }
82
+ mods.sort((a, b) => b.size - a.size);
83
+ return mods;
84
+ }
85
+
86
+ // esbuild metafile JSON
87
+ export function normalizeEsbuild(meta: any): Mod[] {
88
+ const mods: Mod[] = [];
89
+ if (meta.inputs && typeof meta.inputs === 'object') {
90
+ for (const [p, info] of Object.entries<any>(meta.inputs)) {
91
+ const size = info.bytes ?? 0;
92
+ mods.push({ path: p, size });
93
+ }
94
+ }
95
+ mods.sort((a, b) => b.size - a.size);
96
+ return mods;
97
+ }
98
+
99
+ // ---------------- Utilities ------------------
100
+
101
+ export const SIZE_THRESHOLDS = [10 * 1024, 50 * 1024, 100 * 1024]; // bytes
102
+
103
+ export function formatSize(bytes: number): string {
104
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
105
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
106
+ return bytes + ' B';
107
+ }
108
+
109
+ export function totalSize(mods: Mod[]): number {
110
+ return mods.reduce((a, m) => a + m.size, 0);
111
+ }
112
+
113
+ export function topModules(mods: Mod[], n = 10): Mod[] {
114
+ return [...mods].sort((a, b) => b.size - a.size).slice(0, n);
115
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,97 @@
1
+ // ---- CLI -----------------------------------------------------------------
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ import {
8
+ Mod,
9
+ normalizeWebpack,
10
+ normalizeVite,
11
+ normalizeEsbuild,
12
+ treemap,
13
+ draw
14
+ } from './bundle.js';
15
+
16
+ import { renderReport } from './report.js';
17
+
18
+ function main() {
19
+ const program = new Command();
20
+
21
+ program
22
+ .name('bunx-ray')
23
+ .description('ASCII heat-map bundle viewer')
24
+ .argument('<stats>', 'Build stats JSON file')
25
+ .option('--webpack', 'Input is Webpack stats (default)')
26
+ .option('--vite', 'Input is Vite/Rollup stats')
27
+ .option('--esbuild', 'Input is esbuild metafile')
28
+ .option('--cols <number>', 'Terminal columns (default 80)', '80')
29
+ .option('--rows <number>', 'Terminal rows (default 24)', '24')
30
+ .option('--top <number>', 'Show N largest modules (default 10)', '10')
31
+ .option('--no-legend', 'Hide legend line')
32
+ .option('--no-summary', 'Hide summary line')
33
+ .option('--grid-only', 'Only print grid (implies --no-legend --no-summary)')
34
+ .option('--demo', 'Render built-in demo heat-map')
35
+ .parse(process.argv);
36
+
37
+ const opts = program.opts();
38
+ const file = program.args[0];
39
+
40
+ const cols = Number(opts.cols);
41
+ const rows = Number(opts.rows);
42
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
43
+ console.error(chalk.red('Error: --cols and --rows must be numbers'));
44
+ process.exit(1);
45
+ }
46
+
47
+ const raw = fs.readFileSync(path.resolve(process.cwd(), file), 'utf8');
48
+ let stats: any;
49
+ try {
50
+ stats = JSON.parse(raw);
51
+ } catch (e) {
52
+ console.error(chalk.red(`Failed to parse JSON from ${file}`));
53
+ process.exit(1);
54
+ }
55
+
56
+ // Choose adapter based on flags or auto-detect.
57
+ let mods: Mod[] = [];
58
+ if (opts.webpack) {
59
+ mods = normalizeWebpack(stats);
60
+ } else if (opts.vite) {
61
+ mods = normalizeVite(stats);
62
+ } else if (opts.esbuild) {
63
+ mods = normalizeEsbuild(stats);
64
+ } else {
65
+ // auto-detect simple heuristics
66
+ if (stats.inputs && stats.outputs) mods = normalizeEsbuild(stats);
67
+ else if (stats.modules || stats.children) mods = normalizeWebpack(stats);
68
+ else if (stats.output) mods = normalizeVite(stats);
69
+ else {
70
+ console.error(chalk.red('Unable to detect stats format; please pass --webpack | --vite | --esbuild'));
71
+ process.exit(1);
72
+ }
73
+ }
74
+
75
+ if (mods.length === 0) {
76
+ console.error(chalk.yellow('No modules found in stats file.'));
77
+ process.exit(1);
78
+ }
79
+
80
+ const report = renderReport(mods, {
81
+ cols,
82
+ rows,
83
+ top: Number(opts.top ?? 10),
84
+ legend: opts.legend !== false && !opts.gridOnly,
85
+ summary: opts.summary !== false && !opts.gridOnly,
86
+ color: true,
87
+ });
88
+
89
+ if (report.legendLine) console.log(report.legendLine);
90
+ if (report.summaryLine) console.log(report.summaryLine);
91
+
92
+ console.log('\n' + report.grid + '\n');
93
+
94
+ report.tableLines.forEach((l) => console.log(l));
95
+ }
96
+
97
+ main();
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type { Mod, Cell } from './bundle.js';
2
+ export {
3
+ normalizeWebpack,
4
+ normalizeVite,
5
+ normalizeEsbuild,
6
+ treemap,
7
+ draw,
8
+ formatSize,
9
+ totalSize,
10
+ topModules,
11
+ } from './bundle.js';
package/src/report.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { Mod, treemap, draw, formatSize, totalSize, topModules } from './bundle.js';
2
+
3
+ export interface ReportOptions {
4
+ cols: number;
5
+ rows: number;
6
+ top: number;
7
+ legend: boolean;
8
+ summary: boolean;
9
+ color: boolean; // reserved – color disabled for now
10
+ }
11
+
12
+ const SHADES = ['░', '▒', '▓', '█'] as const;
13
+
14
+ function calcThresholds(max: number): number[] {
15
+ return [0.25, 0.5, 0.75].map((p) => max * p);
16
+ }
17
+
18
+ function shadeForSize(size: number, max: number): string {
19
+ const idx = Math.floor((size / max) * (SHADES.length - 1));
20
+ return SHADES[idx];
21
+ }
22
+
23
+ export interface RenderedReport {
24
+ legendLine?: string;
25
+ summaryLine?: string;
26
+ grid: string;
27
+ tableLines: string[];
28
+ }
29
+
30
+ export function renderReport(mods: Mod[], opts: ReportOptions): RenderedReport {
31
+ const { cols, rows, top, legend, summary } = opts;
32
+ const max = Math.max(...mods.map((m) => m.size));
33
+ const thresholds = calcThresholds(max);
34
+
35
+ // grid
36
+ const grid = draw(treemap(mods, cols, rows), cols, rows);
37
+
38
+ // legend
39
+ let legendLine: string | undefined;
40
+ if (legend) {
41
+ legendLine =
42
+ 'Legend ' +
43
+ [
44
+ `${SHADES[3]} >${formatSize(thresholds[2])}`,
45
+ `${SHADES[2]} ${formatSize(thresholds[1])}-${formatSize(thresholds[2])}`,
46
+ `${SHADES[1]} ${formatSize(thresholds[0])}-${formatSize(thresholds[1])}`,
47
+ `${SHADES[0]} <${formatSize(thresholds[0])}`,
48
+ ].join(' ');
49
+ }
50
+
51
+ // summary
52
+ let summaryLine: string | undefined;
53
+ if (summary) {
54
+ summaryLine = `Total bundle: ${formatSize(totalSize(mods))} | modules: ${mods.length}`;
55
+ }
56
+
57
+ // table
58
+ const list = topModules(mods, top);
59
+ const tableLines: string[] = [`Top ${list.length} modules`];
60
+ list.forEach((m, idx) => {
61
+ const shade = shadeForSize(m.size, max);
62
+ const pct = ((m.size / totalSize(mods)) * 100).toFixed(1).padStart(4);
63
+ const name = m.path.length > 28 ? '…' + m.path.slice(-27) : m.path.padEnd(28);
64
+ tableLines.push(`${(idx + 1).toString().padStart(2)} ${shade} ${name} ${formatSize(m.size).padStart(8)} (${pct}%)`);
65
+ });
66
+
67
+ return { legendLine, summaryLine, grid, tableLines };
68
+ }
@@ -0,0 +1,2 @@
1
+ import { add } from './math';
2
+ console.log('Add esbuild', add(10, 15));
@@ -0,0 +1 @@
1
+ export const add = (a: number, b: number): number => a + b;
@@ -0,0 +1,2 @@
1
+ import { add } from './math.ts';
2
+ console.log('Add', add(5, 7));
@@ -0,0 +1 @@
1
+ export const add = (a: number, b: number): number => a + b;
@@ -0,0 +1,5 @@
1
+ import _ from 'lodash';
2
+ import { add } from './math.js';
3
+
4
+ console.log('Sum', add(2, 3));
5
+ console.log(_.chunk([1,2,3,4],2));
@@ -0,0 +1 @@
1
+ export const add = (a, b) => a + b;
@@ -0,0 +1,34 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`normalize + draw > esbuild fixture renders 1`] = `
4
+ "███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
5
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
6
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
7
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
8
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
9
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
10
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
11
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒"
12
+ `;
13
+
14
+ exports[`normalize + draw > vite fixture renders 1`] = `
15
+ "███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
16
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
17
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
18
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
19
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
20
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
21
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒
22
+ ███████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒"
23
+ `;
24
+
25
+ exports[`normalize + draw > webpack fixture renders 1`] = `
26
+ "█████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
27
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
28
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
29
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
30
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
31
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
32
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░
33
+ █████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░"
34
+ `;
@@ -0,0 +1,31 @@
1
+ /// <reference types="vitest" />
2
+
3
+ import { describe, it, expect } from 'vitest';
4
+ import { readFileSync } from 'fs';
5
+ import { normalizeWebpack, normalizeVite, normalizeEsbuild, treemap, draw } from '../src/bundle';
6
+
7
+ const fixturesDir = new URL('../fixtures/', import.meta.url).pathname;
8
+
9
+ function load(name: string) {
10
+ return JSON.parse(readFileSync(`${fixturesDir}${name}`, 'utf8'));
11
+ }
12
+
13
+ describe('normalize + draw', () => {
14
+ it('webpack fixture renders', () => {
15
+ const mods = normalizeWebpack(load('webpack-sample.json'));
16
+ const ascii = draw(treemap(mods, 40, 8), 40, 8);
17
+ expect(ascii).toMatchSnapshot();
18
+ });
19
+
20
+ it('vite fixture renders', () => {
21
+ const mods = normalizeVite(load('vite-sample.json'));
22
+ const ascii = draw(treemap(mods, 40, 8), 40, 8);
23
+ expect(ascii).toMatchSnapshot();
24
+ });
25
+
26
+ it('esbuild fixture renders', () => {
27
+ const mods = normalizeEsbuild(load('esbuild-sample.json'));
28
+ const ascii = draw(treemap(mods, 40, 8), 40, 8);
29
+ expect(ascii).toMatchSnapshot();
30
+ });
31
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execa } from 'execa';
3
+ import path from 'path';
4
+
5
+ const cli = path.resolve('dist/cli.js');
6
+ const fixture = path.resolve('fixtures/webpack-sample.json');
7
+
8
+ describe('CLI smoke tests', () => {
9
+ it('prints legend and summary by default', async () => {
10
+ const { stdout } = await execa('node', [cli, fixture, '--cols', '20', '--rows', '6']);
11
+ expect(stdout).toMatch(/Legend/);
12
+ expect(stdout).toMatch(/Total bundle/);
13
+ });
14
+
15
+ it('grid-only hides legend and summary', async () => {
16
+ const { stdout } = await execa('node', [cli, fixture, '--grid-only', '--cols', '20', '--rows', '6']);
17
+ expect(stdout).not.toMatch(/Legend/);
18
+ expect(stdout).not.toMatch(/Total bundle/);
19
+ });
20
+
21
+ it('respects top flag', async () => {
22
+ const { stdout } = await execa('node', [cli, fixture, '--top', '2', '--cols', '20', '--rows', '6']);
23
+ const lines = stdout.split('\n').filter((l) => l.startsWith(' '));
24
+ // last section lines start with space digit for table
25
+ const tableLines = lines.filter((l) => /\d+ [░▒▓█]/.test(l));
26
+ expect(tableLines.length).toBe(2);
27
+ });
28
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "types": ["node", "vitest"],
13
+ "declaration": true,
14
+ "declarationMap": true
15
+ },
16
+ "include": ["src"]
17
+ }
package/vite.sample.js ADDED
@@ -0,0 +1,15 @@
1
+ import { resolve } from 'path';
2
+ import analyze from 'rollup-plugin-analyzer';
3
+
4
+ export default {
5
+ build: {
6
+ outDir: 'dist-vite',
7
+ emptyOutDir: true,
8
+ rollupOptions: {
9
+ input: resolve('./src-vite/index.ts'),
10
+ plugins: [
11
+ analyze({ summaryOnly: true, json: true, filename: 'dist-vite/stats.json' })
12
+ ]
13
+ }
14
+ }
15
+ };
@@ -0,0 +1,13 @@
1
+ import path from 'path';
2
+
3
+ export default {
4
+ mode: 'production',
5
+ entry: './src-webpack/index.js',
6
+ output: {
7
+ filename: 'bundle.js',
8
+ path: path.resolve('./dist-webpack'),
9
+ },
10
+ stats: {
11
+ preset: 'normal',
12
+ },
13
+ };