diff --git a/.github/workflows/size-data.yml b/.github/workflows/size-data.yml new file mode 100644 index 000000000..8da55d0c2 --- /dev/null +++ b/.github/workflows/size-data.yml @@ -0,0 +1,52 @@ +name: size data + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + collect: + if: github.repository == 'Comfy-Org/ComfyUI_frontend' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4.1.0 + with: + version: 10 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: '24.x' + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Build project + run: pnpm build + + - name: Collect size data + run: node scripts/size-collect.js + + - name: Save PR number & base branch + if: ${{ github.event_name == 'pull_request' }} + run: | + echo ${{ github.event.number }} > ./temp/size/number.txt + echo ${{ github.base_ref }} > ./temp/size/base.txt + + - name: Upload size data + uses: actions/upload-artifact@v4 + with: + name: size-data + path: temp/size diff --git a/.github/workflows/size-report.yml b/.github/workflows/size-report.yml new file mode 100644 index 000000000..caaafd30a --- /dev/null +++ b/.github/workflows/size-report.yml @@ -0,0 +1,104 @@ +name: size report + +on: + workflow_run: + workflows: ['size data'] + types: + - completed + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to report on' + required: true + type: number + run_id: + description: 'Size data workflow run ID' + required: true + type: string + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + size-report: + runs-on: ubuntu-latest + if: > + github.repository == 'Comfy-Org/ComfyUI_frontend' && + ( + (github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success') || + github.event_name == 'workflow_dispatch' + ) + steps: + - uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4.1.0 + with: + version: 10 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: '24.x' + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Download size data + uses: dawidd6/action-download-artifact@v11 + with: + name: size-data + run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }} + path: temp/size + + - name: Set PR number + id: pr-number + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "content=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT + else + echo "content=$(cat temp/size/number.txt)" >> $GITHUB_OUTPUT + fi + + - name: Set base branch + id: pr-base + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "content=main" >> $GITHUB_OUTPUT + else + echo "content=$(cat temp/size/base.txt)" >> $GITHUB_OUTPUT + fi + + - name: Download previous size data + uses: dawidd6/action-download-artifact@v11 + with: + branch: ${{ steps.pr-base.outputs.content }} + workflow: size-data.yml + event: push + name: size-data + path: temp/size-prev + if_no_artifact_found: warn + + - name: Generate size report + run: node scripts/size-report.js > size-report.md + + - name: Read size report + id: size-report + uses: juliangruber/read-file-action@v1 + with: + path: ./size-report.md + + - name: Create or update PR comment + uses: actions-cool/maintain-one-comment@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + number: ${{ steps.pr-number.outputs.content }} + body: | + ${{ steps.size-report.outputs.content }} + + body-include: '' diff --git a/eslint.config.ts b/eslint.config.ts index 0bc70372f..2a4d219a9 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -254,5 +254,17 @@ export default defineConfig([ rules: { 'no-console': 'off' } + }, + { + files: ['scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node + } + }, + rules: { + '@typescript-eslint/no-floating-promises': 'off', + 'no-console': 'off' + } } ]) diff --git a/package.json b/package.json index ffa620955..05265d2cd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js", "build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build", "build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build", + "size:collect": "node scripts/size-collect.js", + "size:report": "node scripts/size-report.js", "collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts", "dev:desktop": "nx dev @comfyorg/desktop-ui", "dev:electron": "nx serve --config vite.electron.config.mts", @@ -86,9 +88,12 @@ "jsdom": "catalog:", "knip": "catalog:", "lint-staged": "catalog:", + "markdown-table": "catalog:", "nx": "catalog:", + "picocolors": "catalog:", "postcss-html": "catalog:", "prettier": "catalog:", + "pretty-bytes": "catalog:", "rollup-plugin-visualizer": "catalog:", "storybook": "catalog:", "stylelint": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6438353ce..3d0b4fa8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,9 +183,15 @@ catalogs: lint-staged: specifier: ^15.2.7 version: 15.2.7 + markdown-table: + specifier: ^3.0.4 + version: 3.0.4 nx: specifier: 21.4.1 version: 21.4.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 pinia: specifier: ^2.1.7 version: 2.2.2 @@ -195,6 +201,9 @@ catalogs: prettier: specifier: ^3.6.2 version: 3.6.2 + pretty-bytes: + specifier: ^7.1.0 + version: 7.1.0 primeicons: specifier: ^7.0.0 version: 7.0.0 @@ -254,7 +263,7 @@ catalogs: version: 3.5.13 vue-component-type-helpers: specifier: ^3.0.7 - version: 3.1.0 + version: 3.1.1 vue-eslint-parser: specifier: ^10.2.0 version: 10.2.0 @@ -585,15 +594,24 @@ importers: lint-staged: specifier: 'catalog:' version: 15.2.7 + markdown-table: + specifier: 'catalog:' + version: 3.0.4 nx: specifier: 'catalog:' version: 21.4.1 + picocolors: + specifier: 'catalog:' + version: 1.1.1 postcss-html: specifier: 'catalog:' version: 1.8.0 prettier: specifier: 'catalog:' version: 3.6.2 + pretty-bytes: + specifier: 'catalog:' + version: 7.1.0 rollup-plugin-visualizer: specifier: 'catalog:' version: 6.0.4(rollup@4.22.4) @@ -647,7 +665,7 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2) vue-component-type-helpers: specifier: 'catalog:' - version: 3.1.0 + version: 3.1.1 vue-eslint-parser: specifier: 'catalog:' version: 10.2.0(eslint@9.35.0(jiti@2.4.2)) @@ -4892,9 +4910,6 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - get-tsconfig@4.7.5: - resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5999,11 +6014,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.8: - resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@5.1.5: resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} engines: {node: ^18 || >=20} @@ -6351,10 +6361,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.1: - resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -6372,6 +6378,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -7473,9 +7483,6 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.1.0: - resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==} - vue-component-type-helpers@3.1.1: resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==} @@ -12585,10 +12592,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-tsconfig@4.7.5: - dependencies: - resolve-pkg-maps: 1.0.0 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -13883,8 +13886,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@3.3.8: {} - nanoid@5.1.5: {} napi-postinstall@0.3.3: {} @@ -14264,14 +14265,14 @@ snapshots: dependencies: htmlparser2: 8.0.2 js-tokens: 9.0.1 - postcss: 8.5.1 - postcss-safe-parser: 6.0.0(postcss@8.5.1) + postcss: 8.5.6 + postcss-safe-parser: 6.0.0(postcss@8.5.6) postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@6.0.0(postcss@8.5.1): + postcss-safe-parser@6.0.0(postcss@8.5.6): dependencies: - postcss: 8.5.1 + postcss: 8.5.6 postcss-safe-parser@7.0.1(postcss@8.5.6): dependencies: @@ -14289,12 +14290,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.1: - dependencies: - nanoid: 3.3.8 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -14309,6 +14304,8 @@ snapshots: prettier@3.6.2: {} + pretty-bytes@7.1.0: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -15321,7 +15318,7 @@ snapshots: tsx@4.19.4: dependencies: esbuild: 0.25.5 - get-tsconfig: 4.7.5 + get-tsconfig: 4.10.1 optionalDependencies: fsevents: 2.3.3 @@ -15673,7 +15670,7 @@ snapshots: vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2): dependencies: esbuild: 0.21.5 - postcss: 8.5.1 + postcss: 8.5.6 rollup: 4.22.4 optionalDependencies: '@types/node': 20.14.10 @@ -15738,8 +15735,6 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.1.0: {} - vue-component-type-helpers@3.1.1: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d766360ab..5954445a7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,10 +62,13 @@ catalog: jsdom: ^26.1.0 knip: ^5.62.0 lint-staged: ^15.2.7 + markdown-table: ^3.0.4 nx: 21.4.1 + picocolors: ^1.1.1 pinia: ^2.1.7 postcss-html: ^1.8.0 prettier: ^3.6.2 + pretty-bytes: ^7.1.0 primeicons: ^7.0.0 primevue: ^4.2.5 rollup-plugin-visualizer: ^6.0.4 diff --git a/scripts/bundle-categories.js b/scripts/bundle-categories.js new file mode 100644 index 000000000..c6fc928e0 --- /dev/null +++ b/scripts/bundle-categories.js @@ -0,0 +1,93 @@ +// @ts-check +/** + * Bundle categorization configuration + * + * This file defines how bundles are categorized in size reports. + * Categories help identify which parts of the application are growing. + */ + +/** + * @typedef {Object} BundleCategory + * @property {string} name - Display name of the category + * @property {string} description - Description of what this category includes + * @property {RegExp[]} patterns - Regex patterns to match bundle files + * @property {number} order - Sort order for display (lower = first) + */ + +/** @type {BundleCategory[]} */ +export const BUNDLE_CATEGORIES = [ + { + name: 'App Entry Points', + description: 'Main application bundles', + patterns: [/^index-.*\.js$/], + order: 1 + }, + { + name: 'Core Views', + description: 'Major application views and screens', + patterns: [/GraphView-.*\.js$/, /UserSelectView-.*\.js$/], + order: 2 + }, + { + name: 'UI Panels', + description: 'Settings and configuration panels', + patterns: [/.*Panel-.*\.js$/], + order: 3 + }, + { + name: 'UI Components', + description: 'Reusable UI components', + patterns: [/Avatar-.*\.js$/, /Badge-.*\.js$/], + order: 4 + }, + { + name: 'Services', + description: 'Business logic and services', + patterns: [/.*Service-.*\.js$/, /.*Store-.*\.js$/], + order: 5 + }, + { + name: 'Utilities', + description: 'Helper functions and utilities', + patterns: [/.*[Uu]til.*\.js$/], + order: 6 + }, + { + name: 'Other', + description: 'Uncategorized bundles', + patterns: [/.*/], // Catch-all pattern + order: 99 + } +] + +/** + * Categorize a bundle file based on its name + * + * @param {string} fileName - The bundle file name (e.g., "assets/GraphView-BnV6iF9h.js") + * @returns {string} - The category name + */ +export function categorizeBundle(fileName) { + // Extract just the file name without path + const baseName = fileName.split('/').pop() || fileName + + // Find the first matching category + for (const category of BUNDLE_CATEGORIES) { + for (const pattern of category.patterns) { + if (pattern.test(baseName)) { + return category.name + } + } + } + + return 'Other' +} + +/** + * Get category metadata by name + * + * @param {string} categoryName - The category name + * @returns {BundleCategory | undefined} - The category metadata + */ +export function getCategoryMetadata(categoryName) { + return BUNDLE_CATEGORIES.find((cat) => cat.name === categoryName) +} diff --git a/scripts/size-collect.js b/scripts/size-collect.js new file mode 100644 index 000000000..f2c839ff4 --- /dev/null +++ b/scripts/size-collect.js @@ -0,0 +1,90 @@ +// @ts-check +import { existsSync } from 'node:fs' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { brotliCompressSync, gzipSync } from 'node:zlib' +import pico from 'picocolors' +import prettyBytes from 'pretty-bytes' + +import { categorizeBundle } from './bundle-categories.js' + +const distDir = path.resolve('dist') +const sizeDir = path.resolve('temp/size') + +/** + * @typedef {Object} SizeResult + * @property {string} file + * @property {string} category + * @property {number} size + * @property {number} gzip + * @property {number} brotli + */ + +run() + +/** + * Main function to collect bundle size data + */ +async function run() { + if (!existsSync(distDir)) { + console.error(pico.red('Error: dist directory does not exist')) + console.error(pico.yellow('Please run "pnpm build" first')) + process.exit(1) + } + + console.log(pico.blue('\nCollecting bundle size data...\n')) + + // Collect main bundle files from dist/assets + const assetsDir = path.join(distDir, 'assets') + const bundles = [] + + if (existsSync(assetsDir)) { + const files = await readdir(assetsDir) + const jsFiles = files.filter( + (file) => file.endsWith('.js') && !file.includes('legacy') + ) + + for (const file of jsFiles) { + const filePath = path.join(assetsDir, file) + const content = await readFile(filePath, 'utf-8') + const size = Buffer.byteLength(content) + const gzip = gzipSync(content).length + const brotli = brotliCompressSync(content).length + const fileName = `assets/${file}` + const category = categorizeBundle(fileName) + + bundles.push({ + file: fileName, + category, + size, + gzip, + brotli + }) + + console.log( + `${pico.green(file)} ${pico.dim(`[${category}]`)} - ` + + `Size: ${prettyBytes(size)} / ` + + `Gzip: ${prettyBytes(gzip)} / ` + + `Brotli: ${prettyBytes(brotli)}` + ) + } + } + + // Create temp/size directory + await mkdir(sizeDir, { recursive: true }) + + // Write individual bundle files + for (const bundle of bundles) { + const fileName = bundle.file.replace(/[/\\]/g, '_').replace('.js', '.json') + await writeFile( + path.join(sizeDir, fileName), + JSON.stringify(bundle, null, 2), + 'utf-8' + ) + } + + console.log( + pico.green(`\n✓ Collected size data for ${bundles.length} bundles\n`) + ) + console.log(pico.blue(`Data saved to: ${sizeDir}\n`)) +} diff --git a/scripts/size-report.js b/scripts/size-report.js new file mode 100644 index 000000000..14dd5d95d --- /dev/null +++ b/scripts/size-report.js @@ -0,0 +1,162 @@ +// @ts-check +import { markdownTable } from 'markdown-table' +import { existsSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import prettyBytes from 'pretty-bytes' + +import { getCategoryMetadata } from './bundle-categories.js' + +/** + * @typedef {Object} SizeResult + * @property {number} size + * @property {number} gzip + * @property {number} brotli + */ + +/** + * @typedef {SizeResult & { file: string, category?: string }} BundleResult + */ + +const currDir = path.resolve('temp/size') +const prevDir = path.resolve('temp/size-prev') +let output = '## Bundle Size Report\n\n' +const sizeHeaders = ['Size', 'Gzip', 'Brotli'] + +run() + +/** + * Main function to generate the size report + */ +async function run() { + if (!existsSync(currDir)) { + console.error('Error: temp/size directory does not exist') + console.error('Please run "pnpm size:collect" first') + process.exit(1) + } + + await renderFiles() + process.stdout.write(output) +} + +/** + * Renders file sizes and diffs between current and previous versions + */ +async function renderFiles() { + /** + * @param {string[]} files + * @returns {string[]} + */ + const filterFiles = (files) => files.filter((file) => file.endsWith('.json')) + + const curr = filterFiles(await readdir(currDir)) + const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : [] + const fileList = new Set([...curr, ...prev]) + + // Group bundles by category + /** @type {Map>} */ + const bundlesByCategory = new Map() + + for (const file of fileList) { + const currPath = path.resolve(currDir, file) + const prevPath = path.resolve(prevDir, file) + + const curr = await importJSON(currPath) + const prev = await importJSON(prevPath) + const fileName = curr?.file || prev?.file || '' + const category = curr?.category || prev?.category || 'Other' + + if (!bundlesByCategory.has(category)) { + bundlesByCategory.set(category, []) + } + + // @ts-expect-error - get is valid + bundlesByCategory.get(category).push({ fileName, curr, prev }) + } + + // Sort categories by their order + const sortedCategories = Array.from(bundlesByCategory.keys()).sort((a, b) => { + const metaA = getCategoryMetadata(a) + const metaB = getCategoryMetadata(b) + return (metaA?.order ?? 99) - (metaB?.order ?? 99) + }) + + let totalSize = 0 + let totalCount = 0 + + // Render each category + for (const category of sortedCategories) { + const bundles = bundlesByCategory.get(category) || [] + if (bundles.length === 0) continue + + const categoryMeta = getCategoryMetadata(category) + output += `### ${category}\n\n` + if (categoryMeta?.description) { + output += `_${categoryMeta.description}_\n\n` + } + + const rows = [] + let categorySize = 0 + + for (const { fileName, curr, prev } of bundles) { + if (!curr) { + // File was deleted + rows.push([`~~${fileName}~~`]) + } else { + rows.push([ + fileName, + `${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`, + `${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`, + `${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}` + ]) + categorySize += curr.size + totalSize += curr.size + totalCount++ + } + } + + // Sort rows by file name within category + rows.sort((a, b) => { + const fileA = a[0].replace(/~~/g, '') + const fileB = b[0].replace(/~~/g, '') + return fileA.localeCompare(fileB) + }) + + output += markdownTable([['File', ...sizeHeaders], ...rows]) + output += `\n\n**Category Total:** ${prettyBytes(categorySize)}\n\n` + } + + // Add overall summary + if (totalCount > 0) { + output += '---\n\n' + output += `**Overall Total Size:** ${prettyBytes(totalSize)}\n` + output += `**Total Bundle Count:** ${totalCount}\n` + } +} + +/** + * Imports JSON data from a specified path + * + * @template T + * @param {string} filePath - Path to the JSON file + * @returns {Promise} The JSON content or undefined if the file does not exist + */ +async function importJSON(filePath) { + if (!existsSync(filePath)) return undefined + return (await import(filePath, { with: { type: 'json' } })).default +} + +/** + * Calculates the difference between the current and previous sizes + * + * @param {number} curr - The current size + * @param {number} [prev] - The previous size + * @returns {string} The difference in pretty format + */ +function getDiff(curr, prev) { + if (prev === undefined) return '' + const diff = curr - prev + if (diff === 0) return '' + const sign = diff > 0 ? '+' : '' + return ` (**${sign}${prettyBytes(diff)}**)` +}