@mui/internal-bundle-size-checker 1.0.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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Utility to load the bundle-size-checker configuration
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import envCi from 'env-ci';
8
+
9
+ /**
10
+ * Attempts to load and parse a single config file
11
+ * @param {string} configPath - Path to the configuration file
12
+ * @returns {Promise<BundleSizeCheckerConfig | null>} The parsed config or null if file doesn't exist
13
+ * @throws {Error} If the file exists but has invalid format
14
+ */
15
+ async function loadConfigFile(configPath) {
16
+ try {
17
+ if (!fs.existsSync(configPath)) {
18
+ return null;
19
+ }
20
+
21
+ // Dynamic import for ESM
22
+ const configUrl = new URL(`file://${configPath}`);
23
+ let { default: config } = await import(configUrl.href);
24
+
25
+ // Handle configs that might be Promise-returning functions
26
+ if (config instanceof Promise) {
27
+ config = await config;
28
+ } else if (typeof config === 'function') {
29
+ config = await config();
30
+ }
31
+
32
+ if (!config.entrypoints || !Array.isArray(config.entrypoints)) {
33
+ throw new Error('Configuration must include an entrypoints array');
34
+ }
35
+
36
+ return config;
37
+ } catch (error) {
38
+ console.error(`Error loading config from ${configPath}:`, error);
39
+ throw error; // Re-throw to indicate failure
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Validates and normalizes an upload configuration object
45
+ * @param {UploadConfig} uploadConfig - The upload configuration to normalize
46
+ * @param {Object} ciInfo - CI environment information
47
+ * @param {string} [ciInfo.branch] - Branch name from CI environment
48
+ * @param {boolean} [ciInfo.isPr] - Whether this is a pull request from CI environment
49
+ * @param {string} [ciInfo.prBranch] - PR branch name from CI environment
50
+ * @param {string} [ciInfo.slug] - Repository slug from CI environment
51
+ * @returns {NormalizedUploadConfig} - Normalized upload config
52
+ * @throws {Error} If required fields are missing
53
+ */
54
+ export function applyUploadConfigDefaults(uploadConfig, ciInfo) {
55
+ const { slug, branch: ciBranch, isPr, prBranch } = ciInfo;
56
+
57
+ // Get repo from config or environment
58
+ const repo = uploadConfig.repo || slug;
59
+ if (!repo) {
60
+ throw new Error(
61
+ 'Missing required field: upload.repo. Please specify a repository (e.g., "mui/material-ui").',
62
+ );
63
+ }
64
+
65
+ // Get branch from config or environment
66
+ const branch = uploadConfig.branch || (isPr ? prBranch : ciBranch);
67
+ if (!branch) {
68
+ throw new Error('Missing required field: upload.branch. Please specify a branch name.');
69
+ }
70
+
71
+ // Return the normalized config
72
+ return {
73
+ repo,
74
+ branch,
75
+ isPullRequest:
76
+ uploadConfig.isPullRequest !== undefined
77
+ ? Boolean(uploadConfig.isPullRequest)
78
+ : Boolean(isPr),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Apply default values to the configuration using CI environment
84
+ * @param {BundleSizeCheckerConfig} config - The loaded configuration
85
+ * @returns {NormalizedBundleSizeCheckerConfig} Configuration with defaults applied
86
+ * @throws {Error} If required fields are missing
87
+ */
88
+ function applyConfigDefaults(config) {
89
+ // Get environment CI information
90
+ /** @type {{ branch?: string, isPr?: boolean, prBranch?: string, slug?: string}} */
91
+ const ciInfo = envCi();
92
+
93
+ // Basic validation to ensure entries have the required structure
94
+ // More detailed validation will be done in the worker
95
+ for (const entry of config.entrypoints) {
96
+ if (typeof entry !== 'string' && (!entry || typeof entry !== 'object')) {
97
+ throw new Error('Each entry must be either a string or an object');
98
+ }
99
+ }
100
+
101
+ // Clone the config to avoid mutating the original
102
+ /** @type {NormalizedBundleSizeCheckerConfig} */
103
+ const result = {
104
+ entrypoints: config.entrypoints.map((entry, i) => {
105
+ if (typeof entry === 'string') {
106
+ // Transform string entries into object entries
107
+ const [importSrc, importName] = entry.split('#');
108
+ if (importName) {
109
+ // For entries like '@mui/material#Button', create an object with import and importedNames
110
+ return {
111
+ id: entry,
112
+ import: importSrc,
113
+ importedNames: [importName],
114
+ };
115
+ }
116
+ // For entries like '@mui/material', create an object with import only
117
+ return {
118
+ id: entry,
119
+ import: importSrc,
120
+ };
121
+ }
122
+
123
+ if (entry && typeof entry === 'object') {
124
+ // For existing object entries, return them as is
125
+ return entry;
126
+ }
127
+
128
+ throw new Error(
129
+ `Invalid entry format config.entrypoints[${i}]. Must be a string or an object.`,
130
+ );
131
+ }),
132
+ upload: null, // Default to disabled
133
+ };
134
+
135
+ // Handle different types of upload value
136
+ if (typeof config.upload === 'boolean') {
137
+ // If upload is false, leave as null
138
+ if (config.upload === false) {
139
+ return result;
140
+ }
141
+
142
+ // If upload is true, create empty object and apply defaults
143
+ if (!ciInfo.slug) {
144
+ throw new Error(
145
+ 'Upload enabled but repository not found in CI environment. Please specify upload.repo in config.',
146
+ );
147
+ }
148
+
149
+ if (!ciInfo.branch && !(ciInfo.isPr && ciInfo.prBranch)) {
150
+ throw new Error(
151
+ 'Upload enabled but branch not found in CI environment. Please specify upload.branch in config.',
152
+ );
153
+ }
154
+
155
+ // Apply defaults to an empty object
156
+ result.upload = applyUploadConfigDefaults({}, ciInfo);
157
+ } else if (config.upload) {
158
+ // It's an object, apply defaults
159
+ result.upload = applyUploadConfigDefaults(config.upload, ciInfo);
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Attempts to load the config file from the given directory
167
+ * @param {string} rootDir - The directory to search for the config file
168
+ * @returns {Promise<NormalizedBundleSizeCheckerConfig>} A promise that resolves to the normalized config object
169
+ */
170
+ export async function loadConfig(rootDir) {
171
+ const configPaths = [
172
+ path.join(rootDir, 'bundle-size-checker.config.js'),
173
+ path.join(rootDir, 'bundle-size-checker.config.mjs'),
174
+ ];
175
+
176
+ for (const configPath of configPaths) {
177
+ // eslint-disable-next-line no-await-in-loop
178
+ const config = await loadConfigFile(configPath);
179
+ if (config) {
180
+ // Apply defaults and return the config
181
+ return applyConfigDefaults(config);
182
+ }
183
+ }
184
+
185
+ // Error out if no config file exists
186
+ throw new Error(
187
+ 'No bundle-size-checker configuration file found. Please create a bundle-size-checker.config.js or bundle-size-checker.config.mjs file in your project root.',
188
+ );
189
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @typedef {Object} BundleSizeCheckerConfig
3
+ * @property {string[]} entrypoints - Array of entrypoints to check size for
4
+ */
5
+
6
+ /**
7
+ * Define a configuration for the bundle size checker.
8
+ * This is just a pass-through function for better TypeScript typing.
9
+ *
10
+ * @param {BundleSizeCheckerConfig} config - Configuration object
11
+ * @returns {BundleSizeCheckerConfig} The configuration object
12
+ */
13
+ export default function defineConfig(config) {
14
+ return config;
15
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ *
3
+ * @param {string} repo - The name of the repository e.g. 'mui/material-ui'
4
+ * @param {string} sha - The commit SHA
5
+ * @returns {Promise<import('./sizeDiff').SizeSnapshot>} - The size snapshot data
6
+ */
7
+ export async function fetchSnapshot(repo, sha) {
8
+ const urlsToTry = [
9
+ `https://s3.eu-central-1.amazonaws.com/mui-org-ci/artifacts/${repo}/${sha}/size-snapshot.json`,
10
+ ];
11
+
12
+ if (repo === 'mui/material-ui') {
13
+ urlsToTry.push(
14
+ `https://s3.eu-central-1.amazonaws.com/mui-org-ci/artifacts/master/${sha}/size-snapshot.json`,
15
+ );
16
+ }
17
+
18
+ let lastError;
19
+ for (const url of urlsToTry) {
20
+ try {
21
+ // eslint-disable-next-line no-await-in-loop
22
+ const response = await fetch(url);
23
+ if (!response.ok) {
24
+ lastError = new Error(`Failed to fetch "${url}", HTTP ${response.status}`);
25
+ continue;
26
+ }
27
+
28
+ return response.json();
29
+ } catch (error) {
30
+ lastError = error;
31
+ continue;
32
+ }
33
+ }
34
+
35
+ throw new Error(`Failed to fetch snapshot`, { cause: lastError });
36
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Format utilities for consistent display of sizes and percentages
3
+ */
4
+
5
+ // Formatter for byte sizes (absolute values) - no sign
6
+ export const byteSizeFormatter = new Intl.NumberFormat(undefined, {
7
+ style: 'unit',
8
+ unit: 'byte',
9
+ notation: 'compact',
10
+ unitDisplay: 'narrow',
11
+ maximumSignificantDigits: 3,
12
+ minimumSignificantDigits: 1,
13
+ });
14
+
15
+ // Formatter for size changes - always show sign
16
+ export const byteSizeChangeFormatter = new Intl.NumberFormat(undefined, {
17
+ ...byteSizeFormatter.resolvedOptions(),
18
+ signDisplay: 'exceptZero',
19
+ });
20
+
21
+ // Formatter for percentage display
22
+ export const displayPercentFormatter = new Intl.NumberFormat(undefined, {
23
+ style: 'percent',
24
+ signDisplay: 'exceptZero',
25
+ minimumFractionDigits: 2,
26
+ maximumFractionDigits: 2,
27
+ useGrouping: true,
28
+ });
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ import defineConfig from './defineConfig.js';
2
+ import { loadConfig } from './configLoader.js';
3
+ import { calculateSizeDiff } from './sizeDiff.js';
4
+ import { renderMarkdownReport, renderMarkdownReportContent } from './renderMarkdownReport.js';
5
+ import { fetchSnapshot } from './fetchSnapshot.js';
6
+
7
+ export {
8
+ defineConfig,
9
+ loadConfig,
10
+ calculateSizeDiff,
11
+ renderMarkdownReport,
12
+ renderMarkdownReportContent,
13
+ fetchSnapshot,
14
+ };
15
+
16
+ /**
17
+ * @typedef {import('./sizeDiff.js').Size} Size
18
+ * @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
19
+ * @typedef {import('./sizeDiff.js').ComparisonResult} ComparisonResult
20
+ */
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @typedef {import('./sizeDiff.js').Size} Size
3
+ * @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
4
+ * @typedef {import('./sizeDiff.js').ComparisonResult} ComparisonResult
5
+ */
6
+
7
+ import { calculateSizeDiff } from './sizeDiff.js';
8
+ import { fetchSnapshot } from './fetchSnapshot.js';
9
+ import { displayPercentFormatter, byteSizeChangeFormatter } from './formatUtils.js';
10
+ /**
11
+ *
12
+ * @param {'▲' | '▼'} symbol
13
+ * @param {'yellow'|'red'|'blue'|'green'} color
14
+ * @returns
15
+ */
16
+ function formatSymbol(symbol, color) {
17
+ return `<sup>\${\\tiny{\\color{${color}}${symbol}}}$</sup>`;
18
+ }
19
+
20
+ /**
21
+ * Generates a symbol based on the relative change value.
22
+ * @param {number|null} relative - The relative change as a Number
23
+ * @returns {string} Formatted size change string with symbol
24
+ */
25
+ function getChangeIcon(relative) {
26
+ if (relative === null) {
27
+ return formatSymbol('▲', 'yellow');
28
+ }
29
+ if (relative === -1) {
30
+ return formatSymbol('▼', 'blue');
31
+ }
32
+ if (relative < 0) {
33
+ return formatSymbol('▼', 'green');
34
+ }
35
+ if (relative > 0) {
36
+ return formatSymbol('▲', 'red');
37
+ }
38
+ return ' ';
39
+ }
40
+
41
+ /**
42
+ * Formats the relative change value for display.
43
+ * @param {number|null} value - The relative change as a Number
44
+ * @returns {string} Formatted relative change string
45
+ */
46
+ function formatRelativeChange(value) {
47
+ if (value === null) {
48
+ return 'new';
49
+ }
50
+ if (value === -1) {
51
+ return 'removed';
52
+ }
53
+ return displayPercentFormatter.format(value);
54
+ }
55
+
56
+ /**
57
+ * Generates a user-readable string from a percentage change.
58
+ * @param {number} absolute - The absolute change as a Number
59
+ * @param {number|null} relative - The relative change as a Number
60
+ * @returns {string} Formatted percentage string with emoji
61
+ */
62
+ function formatChange(absolute, relative) {
63
+ const formattedAbsolute = byteSizeChangeFormatter.format(absolute);
64
+ const formattedChange = formatRelativeChange(relative);
65
+ return `${getChangeIcon(relative)}${formattedAbsolute}<sup>(${formattedChange})</sup>`;
66
+ }
67
+
68
+ /**
69
+ * Generates emphasized change text for a single bundle
70
+ * @param {Size} entry - Bundle entry
71
+ * @returns {string} Formatted change text
72
+ */
73
+ function generateEmphasizedChange({ id: bundle, parsed, gzip }) {
74
+ // increase might be a bug fix which is a nice thing. reductions are always nice
75
+ const changeParsed = formatChange(parsed.absoluteDiff, parsed.relativeDiff);
76
+ const changeGzip = formatChange(gzip.absoluteDiff, gzip.relativeDiff);
77
+
78
+ return `**${bundle}**&emsp;**parsed:**${changeParsed} **gzip:**${changeGzip}`;
79
+ }
80
+
81
+ /**
82
+ * Generates a Markdown report for bundle size changes
83
+ * @param {ComparisonResult} comparison - Comparison result from calculateSizeDiff
84
+ * @param {Object} [options] - Additional options
85
+ * @param {number} [options.visibleLimit=10] - Number of entries to show before collapsing
86
+ * @param {number} [options.parsedSizeChangeThreshold=300] - Threshold for parsed size change by which to show the entry
87
+ * @param {number} [options.gzipSizeChangeThreshold=100] - Threshold for gzipped size change by which to show the entry
88
+ * @returns {string} Markdown report
89
+ */
90
+ export function renderMarkdownReportContent(
91
+ comparison,
92
+ { visibleLimit = 10, parsedSizeChangeThreshold = 300, gzipSizeChangeThreshold = 100 } = {},
93
+ ) {
94
+ let markdownContent = '';
95
+
96
+ markdownContent += `**Total Size Change:**${formatChange(
97
+ comparison.totals.totalParsed,
98
+ comparison.totals.totalParsedPercent,
99
+ )} - **Total Gzip Change:**${formatChange(
100
+ comparison.totals.totalGzip,
101
+ comparison.totals.totalGzipPercent,
102
+ )}\n`;
103
+
104
+ markdownContent += `Files: ${comparison.fileCounts.total} total (${
105
+ comparison.fileCounts.added
106
+ } added, ${comparison.fileCounts.removed} removed, ${comparison.fileCounts.changed} changed)\n\n`;
107
+
108
+ const changedEntries = comparison.entries.filter(
109
+ (entry) => Math.abs(entry.parsed.absoluteDiff) > 0 || Math.abs(entry.gzip.absoluteDiff) > 0,
110
+ );
111
+
112
+ const visibleEntries = [];
113
+ const hiddenEntries = [];
114
+
115
+ for (const entry of changedEntries) {
116
+ const { parsed, gzip } = entry;
117
+ const isSignificantChange =
118
+ Math.abs(parsed.absoluteDiff) > parsedSizeChangeThreshold ||
119
+ Math.abs(gzip.absoluteDiff) > gzipSizeChangeThreshold;
120
+ if (isSignificantChange && visibleEntries.length < visibleLimit) {
121
+ visibleEntries.push(entry);
122
+ } else {
123
+ hiddenEntries.push(entry);
124
+ }
125
+ }
126
+
127
+ const importantChanges = visibleEntries.map(generateEmphasizedChange);
128
+ const hiddenChanges = hiddenEntries.map(generateEmphasizedChange);
129
+
130
+ // Add important changes to markdown
131
+ if (importantChanges.length > 0) {
132
+ // Show the most significant changes first, up to the visible limit
133
+ const visibleChanges = importantChanges.slice(0, visibleLimit);
134
+ markdownContent += `${visibleChanges.join('\n')}`;
135
+ }
136
+
137
+ // If there are more changes, add them in a collapsible details section
138
+ if (hiddenChanges.length > 0) {
139
+ markdownContent += `\n<details>\n<summary>Show ${hiddenChanges.length} more bundle changes</summary>\n\n`;
140
+ markdownContent += `${hiddenChanges.join('\n')}\n\n`;
141
+ markdownContent += `</details>`;
142
+ }
143
+
144
+ return markdownContent;
145
+ }
146
+
147
+ /**
148
+ *
149
+ * @param {PrInfo} prInfo
150
+ * @param {string} [circleciBuildNumber] - The CircleCI build number
151
+ * @returns {URL}
152
+ */
153
+ function getDetailsUrl(prInfo, circleciBuildNumber) {
154
+ const detailedComparisonUrl = new URL(
155
+ `https://frontend-public.mui.com/size-comparison/${prInfo.base.repo.full_name}/diff`,
156
+ );
157
+ detailedComparisonUrl.searchParams.set('prNumber', String(prInfo.number));
158
+ detailedComparisonUrl.searchParams.set('baseRef', prInfo.base.ref);
159
+ detailedComparisonUrl.searchParams.set('baseCommit', prInfo.base.sha);
160
+ detailedComparisonUrl.searchParams.set('headCommit', prInfo.head.sha);
161
+ if (circleciBuildNumber) {
162
+ detailedComparisonUrl.searchParams.set('circleCIBuildNumber', circleciBuildNumber);
163
+ }
164
+ return detailedComparisonUrl;
165
+ }
166
+
167
+ /**
168
+ *
169
+ * @param {PrInfo} prInfo
170
+ * @param {string} [circleciBuildNumber] - The CircleCI build number
171
+ * @returns {Promise<string>} Markdown report
172
+ */
173
+ export async function renderMarkdownReport(prInfo, circleciBuildNumber) {
174
+ let markdownContent = '';
175
+
176
+ const baseCommit = prInfo.base.sha;
177
+ const prCommit = prInfo.head.sha;
178
+ const repo = prInfo.base.repo.full_name;
179
+ const [baseSnapshot, prSnapshot] = await Promise.all([
180
+ fetchSnapshot(repo, baseCommit).catch((error) => {
181
+ console.error(`Error fetching base snapshot: ${error}`);
182
+ return null;
183
+ }),
184
+ fetchSnapshot(repo, prCommit),
185
+ ]);
186
+
187
+ if (!baseSnapshot) {
188
+ markdownContent += `_:no_entry_sign: No bundle size snapshot found for base commit ${baseCommit}._\n\n`;
189
+ }
190
+
191
+ const sizeDiff = calculateSizeDiff(baseSnapshot ?? {}, prSnapshot);
192
+
193
+ const report = renderMarkdownReportContent(sizeDiff);
194
+
195
+ markdownContent += report;
196
+
197
+ markdownContent += `\n\n[Details of bundle changes](${getDetailsUrl(prInfo, circleciBuildNumber)})`;
198
+
199
+ return markdownContent;
200
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @description Represents a single bundle size entry
3
+ * @typedef {Object} SizeSnapshotEntry
4
+ * @property {number} parsed
5
+ * @property {number} gzip
6
+ *
7
+ * @description Represents a single bundle size snapshot
8
+ * @typedef {Object.<string, SizeSnapshotEntry>} SizeSnapshot
9
+ *
10
+ * @description Represents a single bundle size comparison
11
+ * @typedef {Object} Size
12
+ * @property {string} id - Bundle identifier
13
+ * @property {Object} parsed - Parsed size information
14
+ * @property {number} parsed.previous - Previous parsed size
15
+ * @property {number} parsed.current - Current parsed size
16
+ * @property {number} parsed.absoluteDiff - Absolute difference in parsed size
17
+ * @property {number|null} parsed.relativeDiff - Relative difference in parsed size
18
+ * @property {Object} gzip - Gzipped size information
19
+ * @property {number} gzip.previous - Previous gzipped size
20
+ * @property {number} gzip.current - Current gzipped size
21
+ * @property {number} gzip.absoluteDiff - Absolute difference in gzipped size
22
+ * @property {number|null} gzip.relativeDiff - Relative difference in gzipped size
23
+ *
24
+ * @description Represents the comparison results
25
+ * @typedef {Object} ComparisonResult
26
+ * @property {Size[]} entries - Size entries for each bundle
27
+ * @property {Object} totals - Total size information
28
+ * @property {number} totals.totalParsed - Total parsed size difference
29
+ * @property {number} totals.totalGzip - Total gzipped size difference
30
+ * @property {number} totals.totalParsedPercent - Total parsed size percentage difference
31
+ * @property {number} totals.totalGzipPercent - Total gzipped size percentage difference
32
+ * @property {Object} fileCounts - File count information
33
+ * @property {number} fileCounts.added - Number of added files
34
+ * @property {number} fileCounts.removed - Number of removed files
35
+ * @property {number} fileCounts.changed - Number of changed files
36
+ * @property {number} fileCounts.total - Total number of files
37
+ */
38
+
39
+ const nullSnapshot = { parsed: 0, gzip: 0 };
40
+
41
+ /**
42
+ * Calculates size difference between two snapshots
43
+ *
44
+ * @param {SizeSnapshot} baseSnapshot - Base snapshot (previous)
45
+ * @param {SizeSnapshot} targetSnapshot - Target snapshot (current)
46
+ * @returns {ComparisonResult} Comparison result with entries, totals, and file counts
47
+ */
48
+ export function calculateSizeDiff(baseSnapshot, targetSnapshot) {
49
+ const bundleKeys = Object.keys({ ...baseSnapshot, ...targetSnapshot });
50
+ /** @type {Size[]} */
51
+ const results = [];
52
+
53
+ // Track totals
54
+ let totalParsed = 0;
55
+ let totalGzip = 0;
56
+ let totalParsedPrevious = 0;
57
+ let totalGzipPrevious = 0;
58
+
59
+ // Track file counts
60
+ let addedFiles = 0;
61
+ let removedFiles = 0;
62
+ let changedFiles = 0;
63
+
64
+ bundleKeys.forEach((bundle) => {
65
+ const isNewBundle = !baseSnapshot[bundle];
66
+ const isRemovedBundle = !targetSnapshot[bundle];
67
+ const currentSize = targetSnapshot[bundle] || nullSnapshot;
68
+ const previousSize = baseSnapshot[bundle] || nullSnapshot;
69
+
70
+ // Update file counts
71
+ if (isNewBundle) {
72
+ addedFiles += 1;
73
+ } else if (isRemovedBundle) {
74
+ removedFiles += 1;
75
+ } else if (
76
+ currentSize.parsed !== previousSize.parsed ||
77
+ currentSize.gzip !== previousSize.gzip
78
+ ) {
79
+ changedFiles += 1;
80
+ }
81
+
82
+ const parsedDiff = currentSize.parsed - previousSize.parsed;
83
+ const gzipDiff = currentSize.gzip - previousSize.gzip;
84
+
85
+ // Calculate relative diffs with appropriate handling of new/removed bundles
86
+ let parsedRelativeDiff;
87
+ if (isNewBundle) {
88
+ parsedRelativeDiff = null;
89
+ } else if (isRemovedBundle) {
90
+ parsedRelativeDiff = -1;
91
+ } else if (previousSize.parsed) {
92
+ parsedRelativeDiff = currentSize.parsed / previousSize.parsed - 1;
93
+ } else {
94
+ parsedRelativeDiff = 0;
95
+ }
96
+
97
+ let gzipRelativeDiff;
98
+ if (isNewBundle) {
99
+ gzipRelativeDiff = null;
100
+ } else if (isRemovedBundle) {
101
+ gzipRelativeDiff = -1;
102
+ } else if (previousSize.gzip) {
103
+ gzipRelativeDiff = currentSize.gzip / previousSize.gzip - 1;
104
+ } else {
105
+ gzipRelativeDiff = 0;
106
+ }
107
+
108
+ const entry = {
109
+ id: bundle,
110
+ parsed: {
111
+ previous: previousSize.parsed,
112
+ current: currentSize.parsed,
113
+ absoluteDiff: parsedDiff,
114
+ relativeDiff: parsedRelativeDiff,
115
+ },
116
+ gzip: {
117
+ previous: previousSize.gzip,
118
+ current: currentSize.gzip,
119
+ absoluteDiff: gzipDiff,
120
+ relativeDiff: gzipRelativeDiff,
121
+ },
122
+ };
123
+
124
+ results.push(entry);
125
+
126
+ // Update totals
127
+ totalParsed += parsedDiff;
128
+ totalGzip += gzipDiff;
129
+ totalParsedPrevious += previousSize.parsed;
130
+ totalGzipPrevious += previousSize.gzip;
131
+ });
132
+
133
+ // Calculate percentage changes
134
+ const totalParsedPercent = totalParsedPrevious > 0 ? totalParsed / totalParsedPrevious : 0;
135
+ const totalGzipPercent = totalGzipPrevious > 0 ? totalGzip / totalGzipPrevious : 0;
136
+
137
+ // Sort the results
138
+ // Custom sorting:
139
+ // 1. Existing bundles that increased in size (larger increases first)
140
+ // 2. New bundles (larger sizes first)
141
+ // 3. Existing bundles that decreased in size (larger decreases first)
142
+ // 4. Removed bundles (larger sizes first)
143
+ // 5. Unchanged bundles (alphabetically)
144
+ results.sort((entryA, entryB) => {
145
+ // Helper function to determine bundle category (for sorting)
146
+ /** @type {(entry: Size) => number} */
147
+ const getCategory = (entry) => {
148
+ if (entry.parsed.relativeDiff === null) {
149
+ return 2; // New bundle
150
+ }
151
+ if (entry.parsed.relativeDiff === -1) {
152
+ return 4; // Removed bundle
153
+ }
154
+ if (entry.parsed.relativeDiff > 0) {
155
+ return 1; // Increased
156
+ }
157
+ if (entry.parsed.relativeDiff < 0) {
158
+ return 3; // Decreased
159
+ }
160
+ return 5; // Unchanged
161
+ };
162
+
163
+ // Get categories for both bundles
164
+ const categoryA = getCategory(entryA);
165
+ const categoryB = getCategory(entryB);
166
+
167
+ // Sort by category first
168
+ if (categoryA !== categoryB) {
169
+ return categoryA - categoryB;
170
+ }
171
+
172
+ // Within the same category, sort by absolute diff (largest first)
173
+ const diffA = Math.abs(entryA.parsed.absoluteDiff);
174
+ const diffB = Math.abs(entryB.parsed.absoluteDiff);
175
+
176
+ if (diffA !== diffB) {
177
+ return diffB - diffA;
178
+ }
179
+
180
+ // If diffs are the same, sort by name
181
+ return entryA.id.localeCompare(entryB.id);
182
+ });
183
+
184
+ return {
185
+ entries: results,
186
+ totals: {
187
+ totalParsed,
188
+ totalGzip,
189
+ totalParsedPercent,
190
+ totalGzipPercent,
191
+ },
192
+ fileCounts: {
193
+ added: addedFiles,
194
+ removed: removedFiles,
195
+ changed: changedFiles,
196
+ total: bundleKeys.length,
197
+ },
198
+ };
199
+ }