@esmx/core 3.0.0-rc.76 → 3.0.0-rc.78

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/dist/core.d.ts CHANGED
@@ -696,4 +696,20 @@ export declare class Esmx {
696
696
  * ```
697
697
  */
698
698
  getStaticImportPaths(env: BuildEnvironment, specifier: string): Promise<readonly string[] | null>;
699
+ /**
700
+ * Generate bundle size analysis report for the build artifacts.
701
+ *
702
+ * @returns {{ text: string, json: object }} Report with text and JSON formats
703
+ *
704
+ * @example
705
+ * ```ts
706
+ * const report = esmx.generateSizeReport();
707
+ * console.log(report.text);
708
+ * console.log(`Total files: ${report.json.totalFiles}`);
709
+ * ```
710
+ */
711
+ generateSizeReport(): {
712
+ text: string;
713
+ json: import("./utils/file-size-stats").SizeStatsReport;
714
+ };
699
715
  }
package/dist/core.mjs CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  parsePackConfig
15
15
  } from "./pack-config.mjs";
16
16
  import { createCache } from "./utils/cache.mjs";
17
+ import { generateSizeReport } from "./utils/file-size-stats.mjs";
17
18
  import { createClientImportMap, createImportMap } from "./utils/import-map.mjs";
18
19
  import { resolvePath } from "./utils/resolve-path.mjs";
19
20
  import { getImportPreloadInfo as getStaticImportPaths } from "./utils/static-import-lexer.mjs";
@@ -872,6 +873,24 @@ document.head.appendChild(script);
872
873
  }
873
874
  );
874
875
  }
876
+ /**
877
+ * Generate bundle size analysis report for the build artifacts.
878
+ *
879
+ * @returns {{ text: string, json: object }} Report with text and JSON formats
880
+ *
881
+ * @example
882
+ * ```ts
883
+ * const report = esmx.generateSizeReport();
884
+ * console.log(report.text);
885
+ * console.log(`Total files: ${report.json.totalFiles}`);
886
+ * ```
887
+ */
888
+ generateSizeReport() {
889
+ return generateSizeReport(
890
+ this.resolvePath("dist"),
891
+ "{client,server,node}/**/!(.*)"
892
+ );
893
+ }
875
894
  }
876
895
  async function defaultDevApp() {
877
896
  throw new Error("'devApp' function not set");
@@ -0,0 +1,29 @@
1
+ export interface FileInfo {
2
+ path: string;
3
+ relativePath: string;
4
+ size: number;
5
+ gzipSize: number;
6
+ ext: string;
7
+ }
8
+ export interface SizeStatsReport {
9
+ totalFiles: number;
10
+ totalSize: number;
11
+ totalGzipSize: number;
12
+ compressionRatio: number;
13
+ files: FileInfo[];
14
+ byExtension: Record<string, {
15
+ count: number;
16
+ totalSize: number;
17
+ totalGzipSize: number;
18
+ avgSize: number;
19
+ avgGzipSize: number;
20
+ }>;
21
+ }
22
+ export declare function analyzeDirectory(dirPath: string, pattern?: string): SizeStatsReport;
23
+ export declare function formatSize(bytes: number): string;
24
+ export declare function generateTextReport(report: SizeStatsReport): string;
25
+ export declare function generateJsonReport(report: SizeStatsReport): string;
26
+ export declare function generateSizeReport(dirPath: string, pattern?: string): {
27
+ text: string;
28
+ json: SizeStatsReport;
29
+ };
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import { globSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { gzipSync } from "node:zlib";
5
+ function getGzipSize(filePath) {
6
+ try {
7
+ const content = fs.readFileSync(filePath);
8
+ const compressed = gzipSync(content, { level: 9 });
9
+ return compressed.length;
10
+ } catch (error) {
11
+ return fs.statSync(filePath).size;
12
+ }
13
+ }
14
+ function getAllFiles(dirPath, pattern = "**/!(.*)") {
15
+ const files = globSync(pattern, {
16
+ cwd: dirPath
17
+ });
18
+ return files.map((relativePath) => path.resolve(dirPath, relativePath)).filter((filePath) => fs.statSync(filePath).isFile());
19
+ }
20
+ export function analyzeDirectory(dirPath, pattern) {
21
+ if (!fs.existsSync(dirPath)) {
22
+ throw new Error(`Directory does not exist: ${dirPath}`);
23
+ }
24
+ const stat = fs.statSync(dirPath);
25
+ if (!stat.isDirectory()) {
26
+ throw new Error(`Path is not a directory: ${dirPath}`);
27
+ }
28
+ const files = getAllFiles(dirPath, pattern);
29
+ const fileInfos = [];
30
+ const byExtension = {};
31
+ let totalSize = 0;
32
+ let totalGzipSize = 0;
33
+ for (const filePath of files) {
34
+ const fileStat = fs.statSync(filePath);
35
+ const size = fileStat.size;
36
+ const gzipSize = getGzipSize(filePath);
37
+ const relativePath = path.relative(process.cwd(), filePath);
38
+ const ext = path.extname(filePath).toLowerCase() || "(no ext)";
39
+ const fileInfo = {
40
+ path: filePath,
41
+ relativePath,
42
+ size,
43
+ gzipSize,
44
+ ext
45
+ };
46
+ fileInfos.push(fileInfo);
47
+ totalSize += size;
48
+ totalGzipSize += gzipSize;
49
+ if (!byExtension[ext]) {
50
+ byExtension[ext] = {
51
+ count: 0,
52
+ totalSize: 0,
53
+ totalGzipSize: 0,
54
+ avgSize: 0,
55
+ avgGzipSize: 0
56
+ };
57
+ }
58
+ byExtension[ext].count++;
59
+ byExtension[ext].totalSize += size;
60
+ byExtension[ext].totalGzipSize += gzipSize;
61
+ }
62
+ Object.values(byExtension).forEach((group) => {
63
+ group.avgSize = Math.round(group.totalSize / group.count);
64
+ group.avgGzipSize = Math.round(group.totalGzipSize / group.count);
65
+ });
66
+ fileInfos.sort((a, b) => b.size - a.size);
67
+ const compressionRatio = totalSize > 0 ? (totalSize - totalGzipSize) / totalSize * 100 : 0;
68
+ return {
69
+ totalFiles: files.length,
70
+ totalSize,
71
+ totalGzipSize,
72
+ compressionRatio: Math.round(compressionRatio * 100) / 100,
73
+ files: fileInfos,
74
+ byExtension
75
+ };
76
+ }
77
+ export function formatSize(bytes) {
78
+ const units = ["B", "KB", "MB", "GB"];
79
+ let size = bytes;
80
+ let unitIndex = 0;
81
+ while (size >= 1024 && unitIndex < units.length - 1) {
82
+ size /= 1024;
83
+ unitIndex++;
84
+ }
85
+ return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
86
+ }
87
+ export function generateTextReport(report) {
88
+ const lines = [];
89
+ lines.push("\u{1F4CA} Bundle Analysis");
90
+ lines.push("=".repeat(50));
91
+ lines.push("");
92
+ const maxPathLength = Math.max(
93
+ ...report.files.map((f) => f.relativePath.length)
94
+ );
95
+ const sizeHeader = "Size".padStart(10);
96
+ const gzippedHeader = "Gzipped".padStart(10);
97
+ const header = `File${" ".repeat(maxPathLength - 4)} ${sizeHeader} ${gzippedHeader}`;
98
+ lines.push(header);
99
+ lines.push("-".repeat(header.length));
100
+ for (const file of report.files) {
101
+ const paddedPath = file.relativePath.padEnd(maxPathLength);
102
+ const sizeStr = formatSize(file.size).padStart(10);
103
+ const gzipStr = formatSize(file.gzipSize).padStart(10);
104
+ lines.push(`${paddedPath} ${sizeStr} ${gzipStr}`);
105
+ }
106
+ lines.push("");
107
+ lines.push(
108
+ `Total: ${report.totalFiles} files, ${formatSize(report.totalSize)} (gzipped: ${formatSize(report.totalGzipSize)})`
109
+ );
110
+ return lines.join("\n");
111
+ }
112
+ export function generateJsonReport(report) {
113
+ return JSON.stringify(report, null, 2);
114
+ }
115
+ export function generateSizeReport(dirPath, pattern) {
116
+ const json = analyzeDirectory(dirPath, pattern);
117
+ return {
118
+ text: generateTextReport(json),
119
+ json
120
+ };
121
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ import fs from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { assert, describe, test } from "vitest";
5
+ import {
6
+ analyzeDirectory,
7
+ formatSize,
8
+ generateSizeReport,
9
+ generateTextReport
10
+ } from "./file-size-stats.mjs";
11
+ describe("formatSize", () => {
12
+ test("should format bytes correctly", () => {
13
+ assert.equal(formatSize(0), "0 B");
14
+ assert.equal(formatSize(512), "512 B");
15
+ assert.equal(formatSize(1024), "1.0 KB");
16
+ assert.equal(formatSize(1536), "1.5 KB");
17
+ assert.equal(formatSize(1048576), "1.0 MB");
18
+ assert.equal(formatSize(1073741824), "1.0 GB");
19
+ });
20
+ });
21
+ describe("analyzeDirectory", () => {
22
+ test("should throw error for non-existent directory", () => {
23
+ try {
24
+ analyzeDirectory("/non-existent-path");
25
+ assert.fail("Should have thrown an error");
26
+ } catch (error) {
27
+ assert.equal(
28
+ error.message,
29
+ "Directory does not exist: /non-existent-path"
30
+ );
31
+ }
32
+ });
33
+ test("should throw error for file path", () => {
34
+ const tempFile = path.join(tmpdir(), "test-file.txt");
35
+ fs.writeFileSync(tempFile, "test content");
36
+ try {
37
+ try {
38
+ analyzeDirectory(tempFile);
39
+ assert.fail("Should have thrown an error");
40
+ } catch (error) {
41
+ assert.equal(
42
+ error.message,
43
+ `Path is not a directory: ${tempFile}`
44
+ );
45
+ }
46
+ } finally {
47
+ fs.unlinkSync(tempFile);
48
+ }
49
+ });
50
+ test("should analyze empty directory", () => {
51
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), "test-"));
52
+ try {
53
+ const report = analyzeDirectory(tempDir);
54
+ assert.equal(report.totalFiles, 0);
55
+ assert.equal(report.totalSize, 0);
56
+ assert.equal(report.totalGzipSize, 0);
57
+ assert.equal(report.compressionRatio, 0);
58
+ assert.deepEqual(report.files, []);
59
+ assert.deepEqual(report.byExtension, {});
60
+ } finally {
61
+ fs.rmSync(tempDir, { recursive: true, force: true });
62
+ }
63
+ });
64
+ test("should analyze directory with files", () => {
65
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), "test-"));
66
+ try {
67
+ const jsContent = 'console.log("hello world");'.repeat(100);
68
+ const cssContent = "body { color: red; }".repeat(50);
69
+ const jsonContent = JSON.stringify({ test: "data" });
70
+ fs.writeFileSync(path.join(tempDir, "test.js"), jsContent);
71
+ fs.writeFileSync(path.join(tempDir, "style.css"), cssContent);
72
+ fs.writeFileSync(path.join(tempDir, "data.json"), jsonContent);
73
+ const subDir = path.join(tempDir, "subdir");
74
+ fs.mkdirSync(subDir);
75
+ fs.writeFileSync(path.join(subDir, "nested.js"), jsContent);
76
+ const report = analyzeDirectory(tempDir);
77
+ assert.equal(report.totalFiles, 4);
78
+ assert.ok(report.totalSize > 0);
79
+ assert.ok(report.totalGzipSize > 0);
80
+ assert.ok(report.compressionRatio >= 0);
81
+ assert.ok(report.byExtension[".js"]);
82
+ assert.ok(report.byExtension[".css"]);
83
+ assert.ok(report.byExtension[".json"]);
84
+ assert.equal(report.byExtension[".js"].count, 2);
85
+ assert.equal(report.byExtension[".css"].count, 1);
86
+ assert.equal(report.byExtension[".json"].count, 1);
87
+ assert.equal(report.files.length, 4);
88
+ const sizes = report.files.map((f) => f.size);
89
+ assert.deepEqual(
90
+ sizes,
91
+ [...sizes].sort((a, b) => b - a)
92
+ );
93
+ } finally {
94
+ fs.rmSync(tempDir, { recursive: true, force: true });
95
+ }
96
+ });
97
+ });
98
+ describe("generateTextReport", () => {
99
+ test("should generate text report", () => {
100
+ const report = {
101
+ totalFiles: 2,
102
+ totalSize: 2048,
103
+ totalGzipSize: 1024,
104
+ compressionRatio: 50,
105
+ files: [
106
+ {
107
+ path: "/test/large.js",
108
+ relativePath: "large.js",
109
+ size: 1536,
110
+ gzipSize: 768,
111
+ ext: ".js"
112
+ },
113
+ {
114
+ path: "/test/small.css",
115
+ relativePath: "small.css",
116
+ size: 512,
117
+ gzipSize: 256,
118
+ ext: ".css"
119
+ }
120
+ ],
121
+ byExtension: {
122
+ ".js": {
123
+ count: 1,
124
+ totalSize: 1536,
125
+ totalGzipSize: 768,
126
+ avgSize: 1536,
127
+ avgGzipSize: 768
128
+ },
129
+ ".css": {
130
+ count: 1,
131
+ totalSize: 512,
132
+ totalGzipSize: 256,
133
+ avgSize: 512,
134
+ avgGzipSize: 256
135
+ }
136
+ }
137
+ };
138
+ const text = generateTextReport(report);
139
+ assert.ok(text.includes("\u{1F4CA} Bundle Analysis"));
140
+ assert.ok(text.includes("large.js"));
141
+ assert.ok(text.includes("small.css"));
142
+ assert.ok(text.includes("Total: 2 files"));
143
+ });
144
+ });
145
+ describe("generateSizeReport", () => {
146
+ test("should generate report with text and json properties", () => {
147
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), "test-"));
148
+ try {
149
+ fs.writeFileSync(
150
+ path.join(tempDir, "test.js"),
151
+ 'console.log("test");'
152
+ );
153
+ const report = generateSizeReport(tempDir);
154
+ assert.ok(typeof report === "object");
155
+ assert.ok(typeof report.text === "string");
156
+ assert.ok(typeof report.json === "object");
157
+ assert.ok(report.text.includes("\u{1F4CA} Bundle Analysis"));
158
+ assert.ok(!report.text.startsWith("{"));
159
+ assert.ok(report.json.totalFiles !== void 0);
160
+ } finally {
161
+ fs.rmSync(tempDir, { recursive: true, force: true });
162
+ }
163
+ });
164
+ test("should generate report with pattern filter", () => {
165
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), "test-"));
166
+ try {
167
+ fs.writeFileSync(
168
+ path.join(tempDir, "test.js"),
169
+ 'console.log("test");'
170
+ );
171
+ fs.writeFileSync(
172
+ path.join(tempDir, "test.css"),
173
+ "body { color: red; }"
174
+ );
175
+ const report = generateSizeReport(tempDir, "*.js");
176
+ assert.ok(typeof report === "object");
177
+ assert.ok(report.json.totalFiles === 1);
178
+ assert.ok(report.json.files[0].ext === ".js");
179
+ } finally {
180
+ fs.rmSync(tempDir, { recursive: true, force: true });
181
+ }
182
+ });
183
+ });
package/package.json CHANGED
@@ -59,7 +59,7 @@
59
59
  "build": "unbuild"
60
60
  },
61
61
  "dependencies": {
62
- "@esmx/import": "3.0.0-rc.76",
62
+ "@esmx/import": "3.0.0-rc.78",
63
63
  "@types/serialize-javascript": "^5.0.4",
64
64
  "es-module-lexer": "^1.7.0",
65
65
  "find": "^0.3.0",
@@ -77,7 +77,7 @@
77
77
  "unbuild": "3.6.1",
78
78
  "vitest": "3.2.4"
79
79
  },
80
- "version": "3.0.0-rc.76",
80
+ "version": "3.0.0-rc.78",
81
81
  "type": "module",
82
82
  "private": false,
83
83
  "exports": {
@@ -100,5 +100,5 @@
100
100
  "template",
101
101
  "public"
102
102
  ],
103
- "gitHead": "8377e5ae2c59cc2b334272d674deb7a01b7e8fa5"
103
+ "gitHead": "aa44e33ab3e05817977c0fda6148abff8351651f"
104
104
  }
package/src/core.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import type { ImportmapMode } from './render-context';
23
23
  import type { RenderContext, RenderContextOptions } from './render-context';
24
24
  import { type CacheHandle, createCache } from './utils/cache';
25
+ import { generateSizeReport } from './utils/file-size-stats';
25
26
  import { createClientImportMap, createImportMap } from './utils/import-map';
26
27
  import type { Middleware } from './utils/middleware';
27
28
  import { type ProjectPath, resolvePath } from './utils/resolve-path';
@@ -1079,6 +1080,24 @@ document.head.appendChild(script);
1079
1080
  }
1080
1081
  );
1081
1082
  }
1083
+ /**
1084
+ * Generate bundle size analysis report for the build artifacts.
1085
+ *
1086
+ * @returns {{ text: string, json: object }} Report with text and JSON formats
1087
+ *
1088
+ * @example
1089
+ * ```ts
1090
+ * const report = esmx.generateSizeReport();
1091
+ * console.log(report.text);
1092
+ * console.log(`Total files: ${report.json.totalFiles}`);
1093
+ * ```
1094
+ */
1095
+ public generateSizeReport() {
1096
+ return generateSizeReport(
1097
+ this.resolvePath('dist'),
1098
+ '{client,server,node}/**/!(.*)'
1099
+ );
1100
+ }
1082
1101
  }
1083
1102
 
1084
1103
  /**
@@ -0,0 +1,212 @@
1
+ import fs from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { assert, describe, test } from 'vitest';
5
+ import {
6
+ analyzeDirectory,
7
+ formatSize,
8
+ generateSizeReport,
9
+ generateTextReport
10
+ } from './file-size-stats';
11
+
12
+ describe('formatSize', () => {
13
+ test('should format bytes correctly', () => {
14
+ assert.equal(formatSize(0), '0 B');
15
+ assert.equal(formatSize(512), '512 B');
16
+ assert.equal(formatSize(1024), '1.0 KB');
17
+ assert.equal(formatSize(1536), '1.5 KB');
18
+ assert.equal(formatSize(1048576), '1.0 MB');
19
+ assert.equal(formatSize(1073741824), '1.0 GB');
20
+ });
21
+ });
22
+
23
+ describe('analyzeDirectory', () => {
24
+ test('should throw error for non-existent directory', () => {
25
+ try {
26
+ analyzeDirectory('/non-existent-path');
27
+ assert.fail('Should have thrown an error');
28
+ } catch (error) {
29
+ assert.equal(
30
+ (error as Error).message,
31
+ 'Directory does not exist: /non-existent-path'
32
+ );
33
+ }
34
+ });
35
+
36
+ test('should throw error for file path', () => {
37
+ const tempFile = path.join(tmpdir(), 'test-file.txt');
38
+ fs.writeFileSync(tempFile, 'test content');
39
+
40
+ try {
41
+ try {
42
+ analyzeDirectory(tempFile);
43
+ assert.fail('Should have thrown an error');
44
+ } catch (error) {
45
+ assert.equal(
46
+ (error as Error).message,
47
+ `Path is not a directory: ${tempFile}`
48
+ );
49
+ }
50
+ } finally {
51
+ fs.unlinkSync(tempFile);
52
+ }
53
+ });
54
+
55
+ test('should analyze empty directory', () => {
56
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'test-'));
57
+
58
+ try {
59
+ const report = analyzeDirectory(tempDir);
60
+
61
+ assert.equal(report.totalFiles, 0);
62
+ assert.equal(report.totalSize, 0);
63
+ assert.equal(report.totalGzipSize, 0);
64
+ assert.equal(report.compressionRatio, 0);
65
+ assert.deepEqual(report.files, []);
66
+ assert.deepEqual(report.byExtension, {});
67
+ } finally {
68
+ fs.rmSync(tempDir, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ test('should analyze directory with files', () => {
73
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'test-'));
74
+
75
+ try {
76
+ const jsContent = 'console.log("hello world");'.repeat(100);
77
+ const cssContent = 'body { color: red; }'.repeat(50);
78
+ const jsonContent = JSON.stringify({ test: 'data' });
79
+
80
+ fs.writeFileSync(path.join(tempDir, 'test.js'), jsContent);
81
+ fs.writeFileSync(path.join(tempDir, 'style.css'), cssContent);
82
+ fs.writeFileSync(path.join(tempDir, 'data.json'), jsonContent);
83
+
84
+ const subDir = path.join(tempDir, 'subdir');
85
+ fs.mkdirSync(subDir);
86
+ fs.writeFileSync(path.join(subDir, 'nested.js'), jsContent);
87
+
88
+ const report = analyzeDirectory(tempDir);
89
+
90
+ assert.equal(report.totalFiles, 4);
91
+ assert.ok(report.totalSize > 0);
92
+ assert.ok(report.totalGzipSize > 0);
93
+ assert.ok(report.compressionRatio >= 0);
94
+
95
+ assert.ok(report.byExtension['.js']);
96
+ assert.ok(report.byExtension['.css']);
97
+ assert.ok(report.byExtension['.json']);
98
+
99
+ assert.equal(report.byExtension['.js'].count, 2);
100
+ assert.equal(report.byExtension['.css'].count, 1);
101
+ assert.equal(report.byExtension['.json'].count, 1);
102
+
103
+ assert.equal(report.files.length, 4);
104
+
105
+ const sizes = report.files.map((f) => f.size);
106
+ assert.deepEqual(
107
+ sizes,
108
+ [...sizes].sort((a, b) => b - a)
109
+ );
110
+ } finally {
111
+ fs.rmSync(tempDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+ });
115
+
116
+ describe('generateTextReport', () => {
117
+ test('should generate text report', () => {
118
+ const report = {
119
+ totalFiles: 2,
120
+ totalSize: 2048,
121
+ totalGzipSize: 1024,
122
+ compressionRatio: 50,
123
+ files: [
124
+ {
125
+ path: '/test/large.js',
126
+ relativePath: 'large.js',
127
+ size: 1536,
128
+ gzipSize: 768,
129
+ ext: '.js'
130
+ },
131
+ {
132
+ path: '/test/small.css',
133
+ relativePath: 'small.css',
134
+ size: 512,
135
+ gzipSize: 256,
136
+ ext: '.css'
137
+ }
138
+ ],
139
+ byExtension: {
140
+ '.js': {
141
+ count: 1,
142
+ totalSize: 1536,
143
+ totalGzipSize: 768,
144
+ avgSize: 1536,
145
+ avgGzipSize: 768
146
+ },
147
+ '.css': {
148
+ count: 1,
149
+ totalSize: 512,
150
+ totalGzipSize: 256,
151
+ avgSize: 512,
152
+ avgGzipSize: 256
153
+ }
154
+ }
155
+ };
156
+
157
+ const text = generateTextReport(report);
158
+
159
+ assert.ok(text.includes('📊 Bundle Analysis'));
160
+ assert.ok(text.includes('large.js'));
161
+ assert.ok(text.includes('small.css'));
162
+ assert.ok(text.includes('Total: 2 files'));
163
+ });
164
+ });
165
+
166
+ describe('generateSizeReport', () => {
167
+ test('should generate report with text and json properties', () => {
168
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'test-'));
169
+
170
+ try {
171
+ fs.writeFileSync(
172
+ path.join(tempDir, 'test.js'),
173
+ 'console.log("test");'
174
+ );
175
+
176
+ const report = generateSizeReport(tempDir);
177
+
178
+ assert.ok(typeof report === 'object');
179
+ assert.ok(typeof report.text === 'string');
180
+ assert.ok(typeof report.json === 'object');
181
+ assert.ok(report.text.includes('📊 Bundle Analysis'));
182
+ assert.ok(!report.text.startsWith('{'));
183
+ assert.ok(report.json.totalFiles !== undefined);
184
+ } finally {
185
+ fs.rmSync(tempDir, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ test('should generate report with pattern filter', () => {
190
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'test-'));
191
+
192
+ try {
193
+ fs.writeFileSync(
194
+ path.join(tempDir, 'test.js'),
195
+ 'console.log("test");'
196
+ );
197
+ fs.writeFileSync(
198
+ path.join(tempDir, 'test.css'),
199
+ 'body { color: red; }'
200
+ );
201
+
202
+ // Test with pattern that only matches .js files
203
+ const report = generateSizeReport(tempDir, '*.js');
204
+
205
+ assert.ok(typeof report === 'object');
206
+ assert.ok(report.json.totalFiles === 1);
207
+ assert.ok(report.json.files[0].ext === '.js');
208
+ } finally {
209
+ fs.rmSync(tempDir, { recursive: true, force: true });
210
+ }
211
+ });
212
+ });
@@ -0,0 +1,190 @@
1
+ import fs from 'node:fs';
2
+ import { globSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { gzipSync } from 'node:zlib';
5
+
6
+ export interface FileInfo {
7
+ path: string;
8
+ relativePath: string;
9
+ size: number;
10
+ gzipSize: number;
11
+ ext: string;
12
+ }
13
+
14
+ export interface SizeStatsReport {
15
+ totalFiles: number;
16
+ totalSize: number;
17
+ totalGzipSize: number;
18
+ compressionRatio: number;
19
+ files: FileInfo[];
20
+ byExtension: Record<
21
+ string,
22
+ {
23
+ count: number;
24
+ totalSize: number;
25
+ totalGzipSize: number;
26
+ avgSize: number;
27
+ avgGzipSize: number;
28
+ }
29
+ >;
30
+ }
31
+
32
+ function getGzipSize(filePath: string): number {
33
+ try {
34
+ const content = fs.readFileSync(filePath);
35
+ const compressed = gzipSync(content, { level: 9 });
36
+ return compressed.length;
37
+ } catch (error) {
38
+ return fs.statSync(filePath).size;
39
+ }
40
+ }
41
+
42
+ function getAllFiles(dirPath: string, pattern = '**/!(.*)'): string[] {
43
+ const files = globSync(pattern, {
44
+ cwd: dirPath
45
+ });
46
+
47
+ return files
48
+ .map((relativePath) => path.resolve(dirPath, relativePath))
49
+ .filter((filePath) => fs.statSync(filePath).isFile());
50
+ }
51
+
52
+ export function analyzeDirectory(
53
+ dirPath: string,
54
+ pattern?: string
55
+ ): SizeStatsReport {
56
+ if (!fs.existsSync(dirPath)) {
57
+ throw new Error(`Directory does not exist: ${dirPath}`);
58
+ }
59
+
60
+ const stat = fs.statSync(dirPath);
61
+ if (!stat.isDirectory()) {
62
+ throw new Error(`Path is not a directory: ${dirPath}`);
63
+ }
64
+
65
+ const files = getAllFiles(dirPath, pattern);
66
+ const fileInfos: FileInfo[] = [];
67
+ const byExtension: Record<
68
+ string,
69
+ {
70
+ count: number;
71
+ totalSize: number;
72
+ totalGzipSize: number;
73
+ avgSize: number;
74
+ avgGzipSize: number;
75
+ }
76
+ > = {};
77
+
78
+ let totalSize = 0;
79
+ let totalGzipSize = 0;
80
+
81
+ for (const filePath of files) {
82
+ const fileStat = fs.statSync(filePath);
83
+ const size = fileStat.size;
84
+ const gzipSize = getGzipSize(filePath);
85
+ const relativePath = path.relative(process.cwd(), filePath);
86
+ const ext = path.extname(filePath).toLowerCase() || '(no ext)';
87
+
88
+ const fileInfo: FileInfo = {
89
+ path: filePath,
90
+ relativePath,
91
+ size,
92
+ gzipSize,
93
+ ext
94
+ };
95
+
96
+ fileInfos.push(fileInfo);
97
+ totalSize += size;
98
+ totalGzipSize += gzipSize;
99
+
100
+ if (!byExtension[ext]) {
101
+ byExtension[ext] = {
102
+ count: 0,
103
+ totalSize: 0,
104
+ totalGzipSize: 0,
105
+ avgSize: 0,
106
+ avgGzipSize: 0
107
+ };
108
+ }
109
+
110
+ byExtension[ext].count++;
111
+ byExtension[ext].totalSize += size;
112
+ byExtension[ext].totalGzipSize += gzipSize;
113
+ }
114
+
115
+ Object.values(byExtension).forEach((group) => {
116
+ group.avgSize = Math.round(group.totalSize / group.count);
117
+ group.avgGzipSize = Math.round(group.totalGzipSize / group.count);
118
+ });
119
+
120
+ fileInfos.sort((a, b) => b.size - a.size);
121
+
122
+ const compressionRatio =
123
+ totalSize > 0 ? ((totalSize - totalGzipSize) / totalSize) * 100 : 0;
124
+
125
+ return {
126
+ totalFiles: files.length,
127
+ totalSize,
128
+ totalGzipSize,
129
+ compressionRatio: Math.round(compressionRatio * 100) / 100,
130
+ files: fileInfos,
131
+ byExtension
132
+ };
133
+ }
134
+
135
+ export function formatSize(bytes: number): string {
136
+ const units = ['B', 'KB', 'MB', 'GB'];
137
+ let size = bytes;
138
+ let unitIndex = 0;
139
+
140
+ while (size >= 1024 && unitIndex < units.length - 1) {
141
+ size /= 1024;
142
+ unitIndex++;
143
+ }
144
+
145
+ return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
146
+ }
147
+
148
+ export function generateTextReport(report: SizeStatsReport): string {
149
+ const lines: string[] = [];
150
+
151
+ lines.push('📊 Bundle Analysis');
152
+ lines.push('='.repeat(50));
153
+ lines.push('');
154
+
155
+ const maxPathLength = Math.max(
156
+ ...report.files.map((f) => f.relativePath.length)
157
+ );
158
+ const sizeHeader = 'Size'.padStart(10);
159
+ const gzippedHeader = 'Gzipped'.padStart(10);
160
+ const header = `File${' '.repeat(maxPathLength - 4)} ${sizeHeader} ${gzippedHeader}`;
161
+ lines.push(header);
162
+ lines.push('-'.repeat(header.length));
163
+
164
+ for (const file of report.files) {
165
+ const paddedPath = file.relativePath.padEnd(maxPathLength);
166
+ const sizeStr = formatSize(file.size).padStart(10);
167
+ const gzipStr = formatSize(file.gzipSize).padStart(10);
168
+ lines.push(`${paddedPath} ${sizeStr} ${gzipStr}`);
169
+ }
170
+
171
+ lines.push('');
172
+ lines.push(
173
+ `Total: ${report.totalFiles} files, ${formatSize(report.totalSize)} (gzipped: ${formatSize(report.totalGzipSize)})`
174
+ );
175
+
176
+ return lines.join('\n');
177
+ }
178
+
179
+ export function generateJsonReport(report: SizeStatsReport): string {
180
+ return JSON.stringify(report, null, 2);
181
+ }
182
+
183
+ export function generateSizeReport(dirPath: string, pattern?: string) {
184
+ const json = analyzeDirectory(dirPath, pattern);
185
+
186
+ return {
187
+ text: generateTextReport(json),
188
+ json
189
+ };
190
+ }