@mui/internal-bundle-size-checker 1.0.4 → 1.0.5-canary.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-bundle-size-checker",
3
- "version": "1.0.4",
3
+ "version": "1.0.5-canary.1",
4
4
  "description": "Bundle size checker for MUI packages.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -32,23 +32,25 @@
32
32
  "execa": "^7.2.0",
33
33
  "fast-glob": "^3.3.2",
34
34
  "file-loader": "^6.2.0",
35
- "fs-extra": "^11.2.0",
36
35
  "micromatch": "^4.0.8",
37
36
  "piscina": "^4.2.1",
37
+ "rollup-plugin-visualizer": "^6.0.1",
38
38
  "terser-webpack-plugin": "^5.3.10",
39
+ "vite": "^6.3.5",
39
40
  "webpack": "^5.90.3",
40
41
  "webpack-bundle-analyzer": "^4.10.1",
41
42
  "yargs": "^17.7.2"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/env-ci": "^3.1.4",
45
- "@types/fs-extra": "^11.0.4",
46
46
  "@types/micromatch": "^4.0.9",
47
47
  "@types/webpack": "^5.28.5",
48
48
  "@types/webpack-bundle-analyzer": "^4.7.0",
49
49
  "@types/yargs": "^17.0.33"
50
50
  },
51
+ "gitSha": "925798014aef249e6650d8fb67463a86ee675f50",
51
52
  "scripts": {
52
- "typescript": "tsc -p tsconfig.json"
53
+ "typescript": "tsc -p tsconfig.json",
54
+ "test": "pnpm -w test --project @mui/internal-bundle-size-checker"
53
55
  }
54
56
  }
package/src/cli.js CHANGED
@@ -2,15 +2,12 @@
2
2
 
3
3
  import path from 'path';
4
4
  import os from 'os';
5
- import fse from 'fs-extra';
5
+ import fs from 'fs/promises';
6
6
  import yargs from 'yargs';
7
7
  import Piscina from 'piscina';
8
8
  import micromatch from 'micromatch';
9
9
  import { loadConfig } from './configLoader.js';
10
10
  import { uploadSnapshot } from './uploadSnapshot.js';
11
- import { calculateSizeDiff } from './sizeDiff.js';
12
- import { renderMarkdownReportContent, renderMarkdownReport } from './renderMarkdownReport.js';
13
- import { fetchSnapshot } from './fetchSnapshot.js';
14
11
 
15
12
  /**
16
13
  * @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
@@ -34,7 +31,8 @@ async function getWebpackSizes(args, config) {
34
31
  });
35
32
  // Clean and recreate the build directory
36
33
  const buildDir = path.join(rootDir, 'build');
37
- await fse.emptyDir(buildDir);
34
+ await fs.rm(buildDir, { recursive: true, force: true });
35
+ await fs.mkdir(buildDir, { recursive: true });
38
36
 
39
37
  if (
40
38
  !config ||
@@ -92,8 +90,8 @@ async function run(argv) {
92
90
  const bundleSizes = Object.fromEntries(webpackSizes.sort((a, b) => a[0].localeCompare(b[0])));
93
91
 
94
92
  // Ensure output directory exists
95
- await fse.mkdirp(path.dirname(snapshotDestPath));
96
- await fse.writeJSON(snapshotDestPath, bundleSizes, { spaces: 2 });
93
+ await fs.mkdir(path.dirname(snapshotDestPath), { recursive: true });
94
+ await fs.writeFile(snapshotDestPath, JSON.stringify(bundleSizes, null, 2));
97
95
 
98
96
  // eslint-disable-next-line no-console
99
97
  console.log(`Bundle size snapshot written to ${snapshotDestPath}`);
@@ -114,215 +112,6 @@ async function run(argv) {
114
112
  }
115
113
  }
116
114
 
117
- /**
118
- * Resolves a file path that can be relative or absolute
119
- * @param {string} filePath - The file path to resolve
120
- * @returns {string} The resolved absolute path
121
- */
122
- function resolveFilePath(filePath) {
123
- if (path.isAbsolute(filePath)) {
124
- return filePath;
125
- }
126
- return path.resolve(rootDir, filePath);
127
- }
128
-
129
- /**
130
- * Checks if a string is a URL
131
- * @param {string} str - The string to check
132
- * @returns {boolean} Whether the string is a URL
133
- */
134
- function isUrl(str) {
135
- try {
136
- // eslint-disable-next-line no-new
137
- new URL(str);
138
- return true;
139
- } catch {
140
- return false;
141
- }
142
- }
143
-
144
- /**
145
- * Loads a snapshot from a URL (http:, https:, or file: scheme)
146
- * @param {string} source - The source URL
147
- * @returns {Promise<SizeSnapshot>} The loaded snapshot
148
- */
149
- async function loadSnapshot(source) {
150
- // Check if it's a valid URL
151
- if (!isUrl(source)) {
152
- throw new Error(`Invalid URL: ${source}. Use file:, http:, or https: schemes.`);
153
- }
154
-
155
- if (source.startsWith('file:')) {
156
- // Handle file: URL
157
- // Remove file: prefix and handle the rest as a file path
158
- // For file:///absolute/path
159
- let filePath = source.substring(source.indexOf('file:') + 5);
160
-
161
- // Remove leading slashes for absolute paths on this machine
162
- while (
163
- filePath.startsWith('/') &&
164
- !path.isAbsolute(filePath.substring(1)) &&
165
- filePath.length > 1
166
- ) {
167
- filePath = filePath.substring(1);
168
- }
169
-
170
- // Now resolve the path
171
- filePath = resolveFilePath(filePath);
172
-
173
- try {
174
- return await fse.readJSON(filePath);
175
- } catch (/** @type {any} */ error) {
176
- throw new Error(`Failed to read snapshot from ${filePath}: ${error.message}`);
177
- }
178
- }
179
-
180
- // HTTP/HTTPS URL - fetch directly
181
- const response = await fetch(source);
182
- if (!response.ok) {
183
- throw new Error(`Failed to fetch snapshot from ${source}: ${response.statusText}`);
184
- }
185
- const body = await response.json();
186
- return body;
187
- }
188
-
189
- /**
190
- * Handler for the diff command
191
- * @param {DiffCommandArgs} argv - Command line arguments
192
- */
193
- async function diffHandler(argv) {
194
- const { base, head = 'file:./size-snapshot.json', output, reportUrl } = argv;
195
-
196
- if (!base) {
197
- console.error('The --base option is required');
198
- process.exit(1);
199
- }
200
-
201
- try {
202
- // Load snapshots
203
- // eslint-disable-next-line no-console
204
- console.log(`Loading base snapshot from ${base}...`);
205
- const baseSnapshot = await loadSnapshot(base);
206
-
207
- // eslint-disable-next-line no-console
208
- console.log(`Loading head snapshot from ${head}...`);
209
- const headSnapshot = await loadSnapshot(head);
210
-
211
- // Calculate diff
212
- const comparison = calculateSizeDiff(baseSnapshot, headSnapshot);
213
-
214
- // Output
215
- if (output === 'markdown') {
216
- // Generate markdown with optional report URL
217
- let markdownContent = renderMarkdownReportContent(comparison);
218
-
219
- // Add report URL if provided
220
- if (reportUrl) {
221
- markdownContent += `\n\n[Details of bundle changes](${reportUrl})`;
222
- }
223
-
224
- // eslint-disable-next-line no-console
225
- console.log(markdownContent);
226
- } else {
227
- // Default JSON output
228
- // eslint-disable-next-line no-console
229
- console.log(JSON.stringify(comparison, null, 2));
230
- }
231
- } catch (/** @type {any} */ error) {
232
- console.error(`Error: ${error.message}`);
233
- process.exit(1);
234
- }
235
- }
236
-
237
- /**
238
- * Fetches GitHub PR information
239
- * @param {string} owner - Repository owner
240
- * @param {string} repo - Repository name
241
- * @param {number} prNumber - Pull request number
242
- * @returns {Promise<PrInfo>} PR information
243
- */
244
- async function fetchPrInfo(owner, repo, prNumber) {
245
- const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`;
246
-
247
- try {
248
- // eslint-disable-next-line no-console
249
- console.log(`Fetching PR info from ${url}...`);
250
- const response = await fetch(url);
251
-
252
- if (!response.ok) {
253
- throw new Error(`GitHub API request failed: ${response.statusText} (${response.status})`);
254
- }
255
-
256
- return await response.json();
257
- } catch (/** @type {any} */ error) {
258
- console.error(`Failed to fetch PR info: ${error.message}`);
259
- throw error;
260
- }
261
- }
262
-
263
- /**
264
- * Handler for the pr command
265
- * @param {PrCommandArgs} argv - Command line arguments
266
- */
267
- async function prHandler(argv) {
268
- const { prNumber, circleci, output } = argv;
269
-
270
- try {
271
- // Load the config to get the repository information
272
- const config = await loadConfig(rootDir);
273
-
274
- if (!config.upload) {
275
- throw new Error(
276
- 'Upload is not configured. Please enable it in your bundle-size-checker config.',
277
- );
278
- }
279
-
280
- // Extract owner and repo from repository config
281
- const [owner, repo] = config.upload.repo.split('/');
282
-
283
- if (!owner || !repo) {
284
- throw new Error(
285
- `Invalid repository format in config: ${config.upload.repo}. Expected format: "owner/repo"`,
286
- );
287
- }
288
-
289
- // Fetch PR information from GitHub
290
- const prInfo = await fetchPrInfo(owner, repo, prNumber);
291
-
292
- // Generate the report
293
- // eslint-disable-next-line no-console
294
- console.log('Generating bundle size report...');
295
- const report = await renderMarkdownReport(prInfo, circleci);
296
-
297
- // Output
298
- if (output === 'markdown') {
299
- // eslint-disable-next-line no-console
300
- console.log(report);
301
- } else {
302
- // For JSON we need to load the snapshots and calculate differences
303
- const baseCommit = prInfo.base.sha;
304
- const prCommit = prInfo.head.sha;
305
-
306
- // eslint-disable-next-line no-console
307
- console.log(`Fetching base snapshot for commit ${baseCommit}...`);
308
- // eslint-disable-next-line no-console
309
- console.log(`Fetching PR snapshot for commit ${prCommit}...`);
310
-
311
- const [baseSnapshot, prSnapshot] = await Promise.all([
312
- fetchSnapshot(config.upload.repo, baseCommit).catch(() => ({})),
313
- fetchSnapshot(config.upload.repo, prCommit).catch(() => ({})),
314
- ]);
315
-
316
- const comparison = calculateSizeDiff(baseSnapshot, prSnapshot);
317
- // eslint-disable-next-line no-console
318
- console.log(JSON.stringify(comparison, null, 2));
319
- }
320
- } catch (/** @type {any} */ error) {
321
- console.error(`Error: ${error.message}`);
322
- process.exit(1);
323
- }
324
- }
325
-
326
115
  yargs(process.argv.slice(2))
327
116
  // @ts-expect-error
328
117
  .command({
@@ -345,6 +134,11 @@ yargs(process.argv.slice(2))
345
134
  describe: 'Show more detailed information during compilation.',
346
135
  type: 'boolean',
347
136
  })
137
+ .option('vite', {
138
+ default: false,
139
+ describe: 'Use Vite instead of webpack for bundling.',
140
+ type: 'boolean',
141
+ })
348
142
  .option('output', {
349
143
  alias: 'o',
350
144
  describe:
@@ -365,62 +159,6 @@ yargs(process.argv.slice(2))
365
159
  },
366
160
  handler: run,
367
161
  })
368
- // @ts-expect-error
369
- .command({
370
- command: 'diff',
371
- describe: 'Compare two bundle size snapshots',
372
- builder: (cmdYargs) => {
373
- return cmdYargs
374
- .option('base', {
375
- describe: 'Base snapshot URL (file:, http:, or https: scheme)',
376
- type: 'string',
377
- demandOption: true,
378
- })
379
- .option('head', {
380
- describe:
381
- 'Head snapshot URL (file:, http:, or https: scheme), defaults to file:./size-snapshot.json',
382
- type: 'string',
383
- default: 'file:./size-snapshot.json',
384
- })
385
- .option('output', {
386
- alias: 'o',
387
- describe: 'Output format (json or markdown)',
388
- type: 'string',
389
- choices: ['json', 'markdown'],
390
- default: 'json',
391
- })
392
- .option('reportUrl', {
393
- describe: 'URL to the detailed report (optional)',
394
- type: 'string',
395
- });
396
- },
397
- handler: diffHandler,
398
- })
399
- // @ts-expect-error
400
- .command({
401
- command: 'pr <prNumber>',
402
- describe: 'Generate a bundle size report for a GitHub pull request',
403
- builder: (cmdYargs) => {
404
- return cmdYargs
405
- .positional('prNumber', {
406
- describe: 'GitHub pull request number',
407
- type: 'number',
408
- demandOption: true,
409
- })
410
- .option('output', {
411
- alias: 'o',
412
- describe: 'Output format (json or markdown)',
413
- type: 'string',
414
- choices: ['json', 'markdown'],
415
- default: 'markdown', // Default to markdown for PR reports
416
- })
417
- .option('circleci', {
418
- describe: 'CircleCI build number for the report URL (optional)',
419
- type: 'string',
420
- });
421
- },
422
- handler: prHandler,
423
- })
424
162
  .help()
425
163
  .strict(true)
426
164
  .version(false)
@@ -34,3 +34,61 @@ export async function fetchSnapshot(repo, sha) {
34
34
 
35
35
  throw new Error(`Failed to fetch snapshot`, { cause: lastError });
36
36
  }
37
+
38
+ /**
39
+ * Gets parent commits for a given commit SHA using GitHub API
40
+ * @param {string} repo - Repository name (e.g., 'mui/material-ui')
41
+ * @param {string} commit - The commit SHA to start from
42
+ * @param {number} depth - How many commits to retrieve (including the starting commit)
43
+ * @returns {Promise<string[]>} Array of commit SHAs in chronological order (excluding the starting commit)
44
+ */
45
+ async function getParentCommits(repo, commit, depth = 4) {
46
+ try {
47
+ const response = await fetch(
48
+ `https://api.github.com/repos/${repo}/commits?sha=${commit}&per_page=${depth}`,
49
+ );
50
+ if (!response.ok) {
51
+ throw new Error(`GitHub API request failed: ${response.status}`);
52
+ }
53
+
54
+ /** @type {{ sha: string }[]} */
55
+ const commits = await response.json();
56
+ // Skip the first commit (which is the starting commit) and return the rest
57
+ return commits.slice(1).map((commitDetails) => commitDetails.sha);
58
+ } catch (/** @type {any} */ error) {
59
+ console.warn(`Failed to get parent commits for ${commit}: ${error.message}`);
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Attempts to fetch a snapshot with fallback to parent commits
66
+ * @param {string} repo - Repository name
67
+ * @param {string} commit - The commit SHA to start from
68
+ * @param {number} [fallbackDepth=3] - How many parent commits to try as fallback
69
+ * @returns {Promise<{snapshot: import('./sizeDiff').SizeSnapshot | null, actualCommit: string | null}>}
70
+ */
71
+ export async function fetchSnapshotWithFallback(repo, commit, fallbackDepth = 3) {
72
+ // Try the original commit first
73
+ try {
74
+ const snapshot = await fetchSnapshot(repo, commit);
75
+ return { snapshot, actualCommit: commit };
76
+ } catch (/** @type {any} */ error) {
77
+ // fallthrough to parent commits if the snapshot for the original commit fails
78
+ }
79
+
80
+ // Get parent commits and try each one
81
+ const parentCommits = await getParentCommits(repo, commit, fallbackDepth + 1);
82
+
83
+ for (const parentCommit of parentCommits) {
84
+ try {
85
+ // eslint-disable-next-line no-await-in-loop
86
+ const snapshot = await fetchSnapshot(repo, parentCommit);
87
+ return { snapshot, actualCommit: parentCommit };
88
+ } catch {
89
+ // fallthrough to the next parent commit if fetching fails
90
+ }
91
+ }
92
+
93
+ return { snapshot: null, actualCommit: null };
94
+ }
package/src/index.js CHANGED
@@ -1,17 +1,10 @@
1
1
  import defineConfig from './defineConfig.js';
2
2
  import { loadConfig } from './configLoader.js';
3
3
  import { calculateSizeDiff } from './sizeDiff.js';
4
- import { renderMarkdownReport, renderMarkdownReportContent } from './renderMarkdownReport.js';
4
+ import { renderMarkdownReport } from './renderMarkdownReport.js';
5
5
  import { fetchSnapshot } from './fetchSnapshot.js';
6
6
 
7
- export {
8
- defineConfig,
9
- loadConfig,
10
- calculateSizeDiff,
11
- renderMarkdownReport,
12
- renderMarkdownReportContent,
13
- fetchSnapshot,
14
- };
7
+ export { defineConfig, loadConfig, calculateSizeDiff, renderMarkdownReport, fetchSnapshot };
15
8
 
16
9
  /**
17
10
  * @typedef {import('./sizeDiff.js').Size} Size
@@ -5,17 +5,8 @@
5
5
  */
6
6
 
7
7
  import { calculateSizeDiff } from './sizeDiff.js';
8
- import { fetchSnapshot } from './fetchSnapshot.js';
8
+ import { fetchSnapshot, fetchSnapshotWithFallback } from './fetchSnapshot.js';
9
9
  import { displayPercentFormatter, byteSizeChangeFormatter } from './formatUtils.js';
10
- /**
11
- *
12
- * @param {'▲' | '▼'} symbol
13
- * @param {KatexColor} color
14
- * @returns
15
- */
16
- function formatSymbol(symbol, color) {
17
- return `<sup>\${\\tiny{\\color{${color}}${symbol}}}$</sup>`;
18
- }
19
10
 
20
11
  /**
21
12
  * Generates a symbol based on the relative change value.
@@ -24,16 +15,16 @@ function formatSymbol(symbol, color) {
24
15
  */
25
16
  function getChangeIcon(relative) {
26
17
  if (relative === null) {
27
- return formatSymbol('', 'orangered');
18
+ return '🔺';
28
19
  }
29
20
  if (relative === -1) {
30
- return formatSymbol('▼', 'cornflowerblue');
21
+ return '▼';
31
22
  }
32
23
  if (relative < 0) {
33
- return formatSymbol('▼', 'green');
24
+ return '▼';
34
25
  }
35
26
  if (relative > 0) {
36
- return formatSymbol('', 'red');
27
+ return '🔺';
37
28
  }
38
29
  return ' ';
39
30
  }
@@ -75,69 +66,66 @@ function generateEmphasizedChange({ id: bundle, parsed, gzip }) {
75
66
  const changeParsed = formatChange(parsed.absoluteDiff, parsed.relativeDiff);
76
67
  const changeGzip = formatChange(gzip.absoluteDiff, gzip.relativeDiff);
77
68
 
78
- return `**${bundle}**&emsp;**parsed:**${changeParsed} **gzip:**${changeGzip}`;
69
+ return `**${bundle}**&emsp;**parsed:** ${changeParsed} **gzip:** ${changeGzip}`;
79
70
  }
80
71
 
81
72
  /**
82
73
  * Generates a Markdown report for bundle size changes
83
74
  * @param {ComparisonResult} comparison - Comparison result from calculateSizeDiff
84
75
  * @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
76
+ * @param {string[]} [options.track] - Array of bundle IDs to track. If specified, totals will only include tracked bundles and all tracked bundles will be shown prominently
77
+ * @param {number} [options.maxDetailsLines=100] - Maximum number of bundles to show in details section
88
78
  * @returns {string} Markdown report
89
79
  */
90
80
  export function renderMarkdownReportContent(
91
81
  comparison,
92
- { visibleLimit = 10, parsedSizeChangeThreshold = 300, gzipSizeChangeThreshold = 100 } = {},
82
+ { track = [], maxDetailsLines = 100 } = {},
93
83
  ) {
94
84
  let markdownContent = '';
95
85
 
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
- }
86
+ if (track.length > 0) {
87
+ const entryMap = new Map(comparison.entries.map((entry) => [entry.id, entry]));
88
+ const trackedEntries = track.map((bundleId) => {
89
+ const trackedEntry = entryMap.get(bundleId);
90
+ if (!trackedEntry) {
91
+ throw new Error(`Tracked bundle not found in head snapshot: ${bundleId}`);
92
+ }
93
+ return trackedEntry;
94
+ });
95
+ // Show all tracked bundles directly (including unchanged ones)
96
+ const trackedChanges = trackedEntries.map(generateEmphasizedChange);
97
+ markdownContent += `${trackedChanges.join('\n')}`;
98
+ } else {
99
+ markdownContent += `**Total Size Change:** ${formatChange(
100
+ comparison.totals.totalParsed,
101
+ comparison.totals.totalParsedPercent,
102
+ )} - **Total Gzip Change:** ${formatChange(
103
+ comparison.totals.totalGzip,
104
+ comparison.totals.totalGzipPercent,
105
+ )}\n`;
106
+
107
+ markdownContent += `Files: ${comparison.fileCounts.total} total (${
108
+ comparison.fileCounts.added
109
+ } added, ${comparison.fileCounts.removed} removed, ${comparison.fileCounts.changed} changed)\n\n`;
125
110
  }
126
111
 
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`;
112
+ // Show all entries in details section, not just changed ones
113
+ // Filter out tracked bundles to avoid duplication
114
+ const trackedIdSet = new Set(track);
115
+ const detailsEntries = comparison.entries.filter((entry) => !trackedIdSet.has(entry.id));
116
+
117
+ // Cap at maxDetailsLines bundles to avoid overly large reports
118
+ const cappedEntries = detailsEntries.slice(0, maxDetailsLines);
119
+ const hasMore = detailsEntries.length > maxDetailsLines;
120
+
121
+ if (cappedEntries.length > 0) {
122
+ const allChanges = cappedEntries.map(generateEmphasizedChange);
123
+ const bundleWord = cappedEntries.length === 1 ? 'bundle' : 'bundles';
124
+ const summaryText = hasMore
125
+ ? `Show details for ${cappedEntries.length} more ${bundleWord} (${detailsEntries.length - maxDetailsLines} more not shown)`
126
+ : `Show details for ${cappedEntries.length} more ${bundleWord}`;
127
+ markdownContent += `<details>\n<summary>${summaryText}</summary>\n\n`;
128
+ markdownContent += `${allChanges.join('\n')}\n\n`;
141
129
  markdownContent += `</details>`;
142
130
  }
143
131
 
@@ -147,16 +135,19 @@ export function renderMarkdownReportContent(
147
135
  /**
148
136
  *
149
137
  * @param {PrInfo} prInfo
150
- * @param {string} [circleciBuildNumber] - The CircleCI build number
138
+ * @param {Object} [options] - Optional parameters
139
+ * @param {string | null} [options.circleciBuildNumber] - The CircleCI build number
140
+ * @param {string | null} [options.actualBaseCommit] - The actual commit SHA used for comparison (may differ from prInfo.base.sha)
151
141
  * @returns {URL}
152
142
  */
153
- function getDetailsUrl(prInfo, circleciBuildNumber) {
143
+ function getDetailsUrl(prInfo, options = {}) {
144
+ const { circleciBuildNumber, actualBaseCommit } = options;
154
145
  const detailedComparisonUrl = new URL(
155
146
  `https://frontend-public.mui.com/size-comparison/${prInfo.base.repo.full_name}/diff`,
156
147
  );
157
148
  detailedComparisonUrl.searchParams.set('prNumber', String(prInfo.number));
158
149
  detailedComparisonUrl.searchParams.set('baseRef', prInfo.base.ref);
159
- detailedComparisonUrl.searchParams.set('baseCommit', prInfo.base.sha);
150
+ detailedComparisonUrl.searchParams.set('baseCommit', actualBaseCommit || prInfo.base.sha);
160
151
  detailedComparisonUrl.searchParams.set('headCommit', prInfo.head.sha);
161
152
  if (circleciBuildNumber) {
162
153
  detailedComparisonUrl.searchParams.set('circleCIBuildNumber', circleciBuildNumber);
@@ -168,33 +159,40 @@ function getDetailsUrl(prInfo, circleciBuildNumber) {
168
159
  *
169
160
  * @param {PrInfo} prInfo
170
161
  * @param {string} [circleciBuildNumber] - The CircleCI build number
162
+ * @param {Object} [options] - Additional options
163
+ * @param {string[]} [options.track] - Array of bundle IDs to track
164
+ * @param {number} [options.fallbackDepth=3] - How many parent commits to try as fallback when base snapshot is missing
165
+ * @param {number} [options.maxDetailsLines=100] - Maximum number of bundles to show in details section
171
166
  * @returns {Promise<string>} Markdown report
172
167
  */
173
- export async function renderMarkdownReport(prInfo, circleciBuildNumber) {
168
+ export async function renderMarkdownReport(prInfo, circleciBuildNumber, options = {}) {
174
169
  let markdownContent = '';
175
170
 
176
171
  const baseCommit = prInfo.base.sha;
177
172
  const prCommit = prInfo.head.sha;
178
173
  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
- }),
174
+ const { fallbackDepth = 3 } = options;
175
+
176
+ const [baseResult, prSnapshot] = await Promise.all([
177
+ fetchSnapshotWithFallback(repo, baseCommit, fallbackDepth),
184
178
  fetchSnapshot(repo, prCommit),
185
179
  ]);
186
180
 
181
+ const { snapshot: baseSnapshot, actualCommit: actualBaseCommit } = baseResult;
182
+
187
183
  if (!baseSnapshot) {
188
- markdownContent += `_:no_entry_sign: No bundle size snapshot found for base commit ${baseCommit}._\n\n`;
184
+ markdownContent += `_:no_entry_sign: No bundle size snapshot found for base commit ${baseCommit} or any of its ${fallbackDepth} parent commits._\n\n`;
185
+ } else if (actualBaseCommit !== baseCommit) {
186
+ markdownContent += `_:information_source: Using snapshot from parent commit ${actualBaseCommit} (fallback from ${baseCommit})._\n\n`;
189
187
  }
190
188
 
191
189
  const sizeDiff = calculateSizeDiff(baseSnapshot ?? {}, prSnapshot);
192
190
 
193
- const report = renderMarkdownReportContent(sizeDiff);
191
+ const report = renderMarkdownReportContent(sizeDiff, options);
194
192
 
195
193
  markdownContent += report;
196
194
 
197
- markdownContent += `\n\n[Details of bundle changes](${getDetailsUrl(prInfo, circleciBuildNumber)})`;
195
+ markdownContent += `\n\n[Details of bundle changes](${getDetailsUrl(prInfo, { circleciBuildNumber, actualBaseCommit })})`;
198
196
 
199
197
  return markdownContent;
200
198
  }