@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 +16 -0
- package/dist/core.mjs +19 -0
- package/dist/utils/file-size-stats.d.ts +29 -0
- package/dist/utils/file-size-stats.mjs +121 -0
- package/dist/utils/file-size-stats.test.d.ts +1 -0
- package/dist/utils/file-size-stats.test.mjs +183 -0
- package/package.json +3 -3
- package/src/core.ts +19 -0
- package/src/utils/file-size-stats.test.ts +212 -0
- package/src/utils/file-size-stats.ts +190 -0
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.
|
|
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.
|
|
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": "
|
|
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
|
+
}
|