|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +const filesize = require('filesize') |
| 4 | +const numberToWords = require('number-to-words') |
| 5 | +const fs = require('fs') |
| 6 | +const path = require('path') |
| 7 | + |
| 8 | +// import the current and base branch bundle stats |
| 9 | +const currentBundle = require(path.join( |
| 10 | + process.cwd(), |
| 11 | + '.next/analyze/__bundle_analysis.json' |
| 12 | +)) |
| 13 | +const baseBundle = require(path.join( |
| 14 | + process.cwd(), |
| 15 | + '.next/analyze/base/bundle/__bundle_analysis.json' |
| 16 | +)) |
| 17 | + |
| 18 | +// Pull options from `package.json` |
| 19 | +const options = require(path.join( |
| 20 | + process.cwd(), |
| 21 | + 'package.json' |
| 22 | +)).nextBundleAnalysis |
| 23 | + |
| 24 | +const BUDGET = options.budget |
| 25 | +const BUDGET_PERCENT_INCREASE_RED = options.budgetPercentIncreaseRed |
| 26 | + |
| 27 | +// kick it off |
| 28 | +let output = `## 📦 Next.js Bundle Analysis |
| 29 | +
|
| 30 | +This analysis was generated by the [next.js bundle analysis action](#) 🤖 |
| 31 | +
|
| 32 | +` |
| 33 | + |
| 34 | +// pull the global bundle out, we handle this separately |
| 35 | +const globalBundleCurrent = currentBundle.__global |
| 36 | +const globalBundleBase = baseBundle.__global |
| 37 | +delete currentBundle.__global |
| 38 | +delete baseBundle.__global |
| 39 | + |
| 40 | +// calculate the difference between the current bundle and the base branch's |
| 41 | +const globalBundleChanges = |
| 42 | + globalBundleCurrent.gzip !== globalBundleBase.gzip |
| 43 | + ? { |
| 44 | + page: 'global', |
| 45 | + raw: globalBundleCurrent.raw, |
| 46 | + gzip: globalBundleCurrent.gzip, |
| 47 | + gzipDiff: globalBundleCurrent.gzip - globalBundleBase.gzip, |
| 48 | + increase: |
| 49 | + Math.sign(globalBundleCurrent.gzip - globalBundleBase.gzip) > 0, |
| 50 | + } |
| 51 | + : false |
| 52 | + |
| 53 | +// now we're going to go through each of the pages in the current bundle and |
| 54 | +// run analysis on each one. |
| 55 | +const changedPages = [] |
| 56 | +const newPages = [] |
| 57 | + |
| 58 | +for (let page in currentBundle) { |
| 59 | + const currentStats = currentBundle[page] |
| 60 | + const baseStats = baseBundle[page] |
| 61 | + |
| 62 | + // if the page does't appear in the base bundle, it is a new page, we can |
| 63 | + // push this directly to its own category. we also don't compare it to anything |
| 64 | + // because its a new page. |
| 65 | + if (!baseStats) { |
| 66 | + newPages.push({ page, ...currentStats }) |
| 67 | + } else if (currentStats.gzip !== baseStats.gzip) { |
| 68 | + // otherwise, we run a comparsion between the current page and base branch page |
| 69 | + // we push these to their own category for rendering later |
| 70 | + const rawDiff = currentStats.raw - baseStats.raw |
| 71 | + const gzipDiff = currentStats.gzip - baseStats.gzip |
| 72 | + const increase = !!Math.sign(gzipDiff) |
| 73 | + changedPages.push({ page, ...currentStats, rawDiff, gzipDiff, increase }) |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +// with our data in hand, we now get to a bunch of output formatting. |
| 78 | +// we start with any changes to the global bundle. |
| 79 | +if (globalBundleChanges) { |
| 80 | + // start with the headline, which will render differently depending on whether |
| 81 | + // there was an increase of decrease. |
| 82 | + output += `### ${ |
| 83 | + globalBundleChanges.increase ? '⚠️' : '🎉' |
| 84 | + } Global Bundle Size ${ |
| 85 | + globalBundleChanges.increase ? 'Increased' : 'Decreased' |
| 86 | + } |
| 87 | + |
| 88 | +` |
| 89 | + // this is where we actually generate the table including the changes. |
| 90 | + output += markdownTable(globalBundleChanges) |
| 91 | + |
| 92 | + // and we end with some extra details further explaining the data above |
| 93 | + output += `\n<details> |
| 94 | +<summary>Details</summary> |
| 95 | +<p>The <strong>global bundle</strong> is the javascript bundle that loads alongside every page. It is in its own category because its impact is much higher - an increase to its size means that every page on your website loads slower, and a decrease means every page loads faster.</p> |
| 96 | +<p>If you want further insight into what is behind the changes, give <a href='https://www.npmjs.com/package/@next/bundle-analyzer'>@next/bundle-analyzer</a> a try!</p> |
| 97 | +</details>\n\n` |
| 98 | +} |
| 99 | + |
| 100 | +// next up is the newly added pages |
| 101 | +if (newPages.length) { |
| 102 | + // this might seem like too much, but I feel like this type of small detail really |
| 103 | + // matters <3 |
| 104 | + const plural = newPages.length > 1 ? 's' : '' |
| 105 | + output += `### New Page${plural} Added |
| 106 | + |
| 107 | +The following page${plural} ${ |
| 108 | + plural === 's' ? 'were' : 'was' |
| 109 | + } added to the bundle from the code in this PR: |
| 110 | +
|
| 111 | +` |
| 112 | + // as before, run the data in as a table |
| 113 | + output += markdownTable(newPages, globalBundleCurrent) + '\n' |
| 114 | + |
| 115 | + // there is no "details" section here, didnt't seem necessary. i'm open to one being |
| 116 | + // added though! |
| 117 | +} |
| 118 | + |
| 119 | +// finally, we run through the pages that existed in the base branch, still exist in the |
| 120 | +// current branch, and have changed size. |
| 121 | +if (changedPages.length) { |
| 122 | + // same flow here as the others: |
| 123 | + // - headline that adjusts wording based on number of changes |
| 124 | + // - table containing all the resources and info |
| 125 | + // - details section |
| 126 | + const plural = changedPages.length > 1 ? 's' : '' |
| 127 | + output += `### ${titleCase( |
| 128 | + numberToWords.toWords(changedPages.length) |
| 129 | + )} Page${plural} Changed Size |
| 130 | + |
| 131 | +The following page${plural} changed size from the code in this PR compared to its base branch: |
| 132 | +
|
| 133 | +` |
| 134 | + output += markdownTable(changedPages, globalBundleCurrent, globalBundleBase) |
| 135 | + |
| 136 | + // this details section is a bit more responsive, it will render slightly different |
| 137 | + // details depending on whether a budget is being used, since the information presented |
| 138 | + // is quite different. |
| 139 | + output += `\n<details> |
| 140 | +<summary>Details</summary> |
| 141 | +<p>Only the gzipped size is provided here based on <a href='https://twitter.com/slightlylate/status/1412851269211811845'>an expert tip</a>.</p> |
| 142 | +<p><strong>First Load</strong> is the size of the global bundle plus the bundle for the individual page. If a user were to show up to your website and land on a given page, the first load size represents the amount of javascript that user would need to download. If <code>next/link</code> is used, subsequent page loads would only need to download that page's bundle (the number in the "Size" column), since the global bundle has already been downloaded.</p> |
| 143 | +${ |
| 144 | + BUDGET && globalBundleCurrent |
| 145 | + ? `<p>The "Budget %" column shows what percentage of your performance budget the <strong>First Load</strong> total takes up. For example, if your budget was 100kb, and a given page's first load size was 10kb, it would be 10% of your budget. You can also see how much this has increased or decreased compared to the base branch of your PR. If this percentage has increased by ${BUDGET_PERCENT_INCREASE_RED}% or more, there will be a red status indicator applied, indicating that special attention should be given to this. If you see "+/- <0.01%" it means that there was a change in bundle size, but it is a trivial enough amount that it can be ignored.</p>` |
| 146 | + : `<p>Next to the size is how much the size has increased or decreased compared with the base branch of this PR. If this percentage has increased by ${BUDGET_PERCENT_INCREASE_RED}% or more, there will be a red status indicator applied, indicating that special attention should be given to this.` |
| 147 | +} |
| 148 | +</details>\n` |
| 149 | +} |
| 150 | + |
| 151 | +// and finally, if there are no changes at all, we try to be clear about that |
| 152 | +if (!newPages.length && !changedPages.length && !globalBundleChanges) { |
| 153 | + output += 'This PR introduced no changes to the javascript bundle 🙌' |
| 154 | +} |
| 155 | + |
| 156 | +// we add this tag so that our action can be able to easily and consistently find the |
| 157 | +// right comment to edit as more commits are pushed. |
| 158 | +output += '<!-- __NEXTJS_BUNDLE -->' |
| 159 | + |
| 160 | +// log the output, mostly for testing and debugging. this will show up in the |
| 161 | +// github actions console. |
| 162 | +console.log(output) |
| 163 | + |
| 164 | +// and to cap it off, we write the output to a file which is later read in as comment |
| 165 | +// contents by the actions workflow. |
| 166 | +fs.writeFileSync( |
| 167 | + path.join(process.cwd(), '.next/analyze/__bundle_analysis_comment.txt'), |
| 168 | + output.trim() |
| 169 | +) |
| 170 | + |
| 171 | +// Util Functions |
| 172 | + |
| 173 | +// this is where the vast majority of the complexity lives, its a single function |
| 174 | +// that renders a markdown table displaying a wide range of bundle size data in a |
| 175 | +// wide variety of different ways. this could potentially be improved by splitting it |
| 176 | +// up into several different functions for rendering the different tables we produce |
| 177 | +// (new pages, changed pages, global bundle) |
| 178 | +function markdownTable(_data, globalBundleCurrent, globalBundleBase) { |
| 179 | + const data = [].concat(_data) |
| 180 | + // the table renders different depending on whether the budget option is enabled |
| 181 | + // and also some tables do not run budget diffs (new, global) |
| 182 | + const showBudget = globalBundleCurrent && BUDGET |
| 183 | + const showBudgetDiff = BUDGET && !!globalBundleBase |
| 184 | + |
| 185 | + // first we set up the table headers |
| 186 | + return `Page | Size (compressed) | ${ |
| 187 | + globalBundleCurrent ? `First Load |` : '' |
| 188 | + }${showBudgetDiff ? ` % of Budget (\`${filesize(BUDGET)}\`) |` : ''} |
| 189 | +|---|---|${globalBundleCurrent ? '---|' : ''}${showBudget ? '---|' : ''} |
| 190 | +${data |
| 191 | + .map((d) => { |
| 192 | + // next, we go through each item in the bundle data that was passed in and render |
| 193 | + // a row for it. a couple calculations are run upfront to make rendering easier. |
| 194 | + const firstLoadSize = globalBundleCurrent |
| 195 | + ? d.gzip + globalBundleCurrent.gzip |
| 196 | + : 0 |
| 197 | +
|
| 198 | + const budgetPercentage = showBudget |
| 199 | + ? ((firstLoadSize / BUDGET) * 100).toFixed(2) |
| 200 | + : 0 |
| 201 | + const previousBudgetPercentage = |
| 202 | + globalBundleBase && d.gzipDiff |
| 203 | + ? ( |
| 204 | + ((globalBundleCurrent.gzip + d.gzip + d.gzipDiff) / BUDGET) * |
| 205 | + 100 |
| 206 | + ).toFixed(2) |
| 207 | + : 0 |
| 208 | + const budgetChange = previousBudgetPercentage |
| 209 | + ? (previousBudgetPercentage - budgetPercentage).toFixed(2) |
| 210 | + : 0 |
| 211 | +
|
| 212 | + return ( |
| 213 | + `| \`${d.page}\`` + |
| 214 | + renderSize(d, showBudgetDiff) + |
| 215 | + renderFirstLoad(globalBundleCurrent, firstLoadSize) + |
| 216 | + renderBudgetPercentage( |
| 217 | + showBudget, |
| 218 | + budgetPercentage, |
| 219 | + previousBudgetPercentage, |
| 220 | + budgetChange |
| 221 | + ) + |
| 222 | + ' |\n' |
| 223 | + ) |
| 224 | + }) |
| 225 | + .join('')}` |
| 226 | +} |
| 227 | + |
| 228 | +// as long as global bundle is passed, render the first load size, which is the global |
| 229 | +// bundle plus the size of the current page, representing the total JS required |
| 230 | +// in order to land on that page. |
| 231 | +function renderFirstLoad(globalBundleCurrent, firstLoadSize) { |
| 232 | + if (!globalBundleCurrent) return '' |
| 233 | + return ` | ${filesize(firstLoadSize)}` |
| 234 | +} |
| 235 | + |
| 236 | +// renders the bundle size of the current page. if there is a diff from the base branch |
| 237 | +// size of the page, also displays the size difference, unless there is a budget set and |
| 238 | +// the budget has a diff from the base branch, in which case the diff is not rendered. |
| 239 | +function renderSize(d, showBudgetDiff) { |
| 240 | + const gzd = d.gzipDiff |
| 241 | + const percentChange = (gzd / d.gzip) * 100 |
| 242 | + return ` | \`${filesize(d.gzip)}\`${ |
| 243 | + gzd && !showBudgetDiff |
| 244 | + ? ` _(${renderStatusIndicator(percentChange)}${filesize(gzd)})_` |
| 245 | + : '' |
| 246 | + }` |
| 247 | +} |
| 248 | + |
| 249 | +// renders the percentage of the budget taken up by the current page's first load js |
| 250 | +// for changed pages, also renders the percent change compared to the base branch size |
| 251 | +function renderBudgetPercentage( |
| 252 | + showBudget, |
| 253 | + budgetPercentage, |
| 254 | + previousBudgetPercentage, |
| 255 | + budgetChange |
| 256 | +) { |
| 257 | + if (!showBudget) return '' |
| 258 | + |
| 259 | + // we round to 2 decimal places for number values, if there was a change smaller than that |
| 260 | + // its displayed as "+/- <0.01%", signaling that it's not a consequential change, but it |
| 261 | + // still is a change technically, so we still show it. |
| 262 | + const budgetChangeText = ` _(${renderStatusIndicator(budgetChange)}${ |
| 263 | + budgetChange < 0.01 && budgetChange > -0.01 |
| 264 | + ? '+/- <0.01%' |
| 265 | + : budgetChange + '%' |
| 266 | + })_` |
| 267 | + |
| 268 | + // only render the budget change for changed pages (indicated by previousBudgetPercentage |
| 269 | + // being passed in) |
| 270 | + return ` | ${budgetPercentage}%${ |
| 271 | + previousBudgetPercentage ? budgetChangeText : '' |
| 272 | + }` |
| 273 | +} |
| 274 | + |
| 275 | +// given a percentage that a metric has changed, renders a colored status indicator |
| 276 | +// this makes it easier to call attention to things that need attention |
| 277 | +// |
| 278 | +// in general: |
| 279 | +// - yellow means "keep an eye on this" |
| 280 | +// - red means "this is a problem" |
| 281 | +// - green means "this is a win" |
| 282 | +function renderStatusIndicator(percentageChange) { |
| 283 | + let res = '' |
| 284 | + if (percentageChange > 0 && percentageChange < BUDGET_PERCENT_INCREASE_RED) { |
| 285 | + res += '🟡 +' |
| 286 | + } else if (percentageChange >= BUDGET_PERCENT_INCREASE_RED) { |
| 287 | + res += '🔴 +' |
| 288 | + } else if (percentageChange < 0.01 && percentageChange > -0.01) { |
| 289 | + res += '' |
| 290 | + } else { |
| 291 | + res += '🟢 ' |
| 292 | + } |
| 293 | + return res |
| 294 | +} |
| 295 | + |
| 296 | +function titleCase(str) { |
| 297 | + return str.replace(/\w\S*/g, (txt) => { |
| 298 | + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() |
| 299 | + }) |
| 300 | +} |
0 commit comments