/** * Generates a markdown bundle size report from measurement JSON data. * * Usage: * node bundle-size-report.js --pr pr-size.json [--base base-size.json] * * When --base is omitted, generates a report showing current sizes only (no diff). * When --base is provided, generates a comparison report with diffs and status icons. * * Reads JSON arrays of { name, size, type } entries produced by bundle-size.js. */ import { readFileSync } from 'node:fs'; const ESC = '\x1b['; const ansi = { bold: (s) => `${ESC}1m${s}${ESC}22m`, dim: (s) => `${ESC}2m${s}${ESC}22m`, cyan: (s) => `${ESC}36m${s}${ESC}39m`, yellow: (s) => `${ESC}33m${s}${ESC}39m`, white: (s) => `${ESC}37m${s}${ESC}39m`, green: (s) => `${ESC}32m${s}${ESC}39m`, }; function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; return `${(bytes / 1024).toFixed(2)} kB`; } function formatDelta(current, previous) { if (previous === undefined) return { bytes: 'โ€”', pct: '' }; const diff = current - previous; if (diff === 0) return { bytes: '0 B', pct: '0%' }; const sign = diff > 0 ? '+' : '-'; const pct = Math.abs((diff / previous) * 100).toFixed(1); return { bytes: `${sign}${formatBytes(Math.abs(diff))}`, pct: `${sign}${pct}%`, }; } function statusIcon(current, previous) { if (previous === undefined) return '๐Ÿ†•'; const diff = current - previous; if (diff === 0) return 'โœ…'; if (diff < 0) return '๐Ÿ”ฝ'; const pct = (diff / previous) * 100; return pct > 10 ? '๐Ÿ”ด' : '๐Ÿ”บ'; } function deltaBar(current, previous, maxAbsPct) { const width = 8; if (previous === undefined || maxAbsPct === 0) return 'โ–‘'.repeat(width); const pct = Math.abs(((current - previous) / previous) * 100); const filled = Math.round((pct / maxAbsPct) * width); return 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(width - filled); } /** Group entries by package: @videojs/utils/* -> utils, @videojs/store/* -> store */ function groupByPackage(entries) { const groups = new Map(); for (const entry of entries) { const match = entry.name.match(/^@videojs\/([^/]+)/); const pkg = match ? match[1] : 'other'; if (!groups.has(pkg)) groups.set(pkg, []); groups.get(pkg).push(entry); } return groups; } function computePackageData(groups, baseMap) { const pkgData = []; let grandTotalCurrent = 0; let grandTotalBase = 0; for (const [pkg, entries] of groups) { const rootEntries = entries.filter((e) => e.type === 'root'); const subEntries = entries.filter((e) => e.type === 'subpath'); const pkgTotalCurrent = rootEntries.reduce((s, e) => s + e.size, 0) + subEntries.reduce((s, e) => s + e.size, 0); const pkgTotalBase = rootEntries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0) + subEntries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0); const hasBase = entries.some((e) => baseMap[e.name] !== undefined); grandTotalCurrent += pkgTotalCurrent; grandTotalBase += pkgTotalBase; pkgData.push({ pkg, entries, rootEntries, subEntries, pkgTotalCurrent, pkgTotalBase, hasBase, }); } return { pkgData, grandTotalCurrent, grandTotalBase }; } /** Generate a comparison report (PR vs base). */ function generateComparisonReport(current, base) { const baseMap = Object.fromEntries(base.map((e) => [e.name, e.size])); const groups = groupByPackage(current); const { pkgData, grandTotalCurrent, grandTotalBase } = computePackageData( groups, baseMap, ); const maxAbsPct = Math.max( ...pkgData.map((p) => { if (!p.hasBase || p.pkgTotalBase === 0) return 0; return Math.abs( ((p.pkgTotalCurrent - p.pkgTotalBase) / p.pkgTotalBase) * 100, ); }), ); // Overview table const overview = []; overview.push('| Package | Size | Diff | | % | |'); overview.push('|---|--:|--:|---|--:|:-:|'); for (const p of pkgData) { const d = formatDelta( p.pkgTotalCurrent, p.hasBase ? p.pkgTotalBase : undefined, ); const icon = p.hasBase ? statusIcon(p.pkgTotalCurrent, p.pkgTotalBase) : ''; const bar = `\`${deltaBar(p.pkgTotalCurrent, p.hasBase ? p.pkgTotalBase : undefined, maxAbsPct)}\``; overview.push( `| **@videojs/${p.pkg}** | **${formatBytes(p.pkgTotalCurrent)}** | ${p.hasBase ? d.bytes : 'โ€”'} | ${bar} | ${p.hasBase ? d.pct : ''} | ${icon} |`, ); } // Detail sections โ€” only for packages with multiple entries const details = []; const pkgsWithSubs = pkgData.filter((p) => p.entries.length > 1); if (pkgsWithSubs.length > 0) { details.push('#### Entry Breakdown'); details.push(''); details.push( 'Subpath sizes are the additional bytes on top of the root entry point, measured by bundling root + subpath together and subtracting the root-only size.', ); details.push(''); for (const p of pkgsWithSubs) { const { pkg, rootEntries, subEntries, pkgTotalCurrent, pkgTotalBase, hasBase, } = p; details.push('
'); details.push(`@videojs/${pkg}`); details.push(''); details.push('| Entry | Base | PR | Diff | % | |'); details.push('|---|--:|--:|--:|--:|:-:|'); for (const entry of [...rootEntries, ...subEntries]) { const displayName = entry.name.replace(`@videojs/${pkg}`, '') || '.'; const label = displayName === '.' ? displayName : `.${displayName}`; const prev = baseMap[entry.name]; const d = formatDelta(entry.size, prev); details.push( `| \`${label}\` | ${prev !== undefined ? formatBytes(prev) : 'โ€”'} | **${formatBytes(entry.size)}** | ${d.bytes} | ${d.pct} | ${statusIcon(entry.size, prev)} |`, ); } if (rootEntries.length + subEntries.length > 1) { const d = formatDelta( pkgTotalCurrent, hasBase ? pkgTotalBase : undefined, ); details.push( `| **total** | **${hasBase ? formatBytes(pkgTotalBase) : 'โ€”'}** | **${formatBytes(pkgTotalCurrent)}** | **${hasBase ? d.bytes : 'โ€”'}** | **${hasBase ? d.pct : ''}** | |`, ); } details.push(''); details.push('
'); details.push(''); } } const grandDelta = formatDelta(grandTotalCurrent, grandTotalBase); const marker = ''; return [ marker, '### ๐Ÿ“ฆ Bundle Size Report', '', ...overview, '', `**Total: ${formatBytes(grandTotalCurrent)}**${grandTotalBase ? ` ยท ${grandDelta.bytes} ยท ${grandDelta.pct}` : ''}`, '', '---', '', ...details, '---', '', '
', 'โ„น๏ธ How to interpret', '', 'Sizes are minified + brotli, measured with esbuild.', 'Package totals are computed as root size + marginal subpath costs.', 'Subpath marginal cost = (root + subpath bundled together) โˆ’ root alone.', '', '| Icon | Meaning |', '|---|---|', '| โœ… | No change |', '| ๐Ÿ”บ | Increased โ‰ค 10% |', '| ๐Ÿ”ด | Increased > 10% |', '| ๐Ÿ”ฝ | Decreased |', '| ๐Ÿ†• | New (no baseline) |', '', 'Run `pnpm size` locally to check current sizes.', '
', ].join('\n'); } /** * Render rows as a padded, aligned table for terminal output. * * Each row is an array of cell values. Cells can be plain strings or * `{ text, style }` objects where `style` is a ansi chain (e.g. ansi.bold). * Alignment and padding use the plain text width; ANSI codes are ignored. * * The first row is treated as a dim header. */ function printTable(rows) { if (rows.length === 0) return ''; const text = (cell) => (typeof cell === 'string' ? cell : cell.text); const style = (cell) => typeof cell === 'string' ? (s) => s : cell.style ?? ((s) => s); const cols = rows[0].length; const widths = Array.from({ length: cols }, () => 0); for (const row of rows) { for (let i = 0; i < cols; i++) { widths[i] = Math.max(widths[i], text(row[i]).length); } } const sep = ansi.dim( `โ”€${widths.map((w) => 'โ”€'.repeat(w)).join('โ”€โ”ผโ”€')}โ”€`, ); const lines = []; for (let r = 0; r < rows.length; r++) { const cells = rows[r].map((cell, i) => { const t = text(cell); const padded = i === cols - 1 ? t.padStart(widths[i]) : t.padEnd(widths[i]); // Header row is dim, data rows use their own style return r === 0 ? ansi.dim(padded) : style(cell)(padded); }); lines.push(` ${cells.join(ansi.dim(' โ”‚ '))} `); if (r === 0) lines.push(sep); } return lines.join('\n'); } /** Color a size value based on magnitude. */ function colorSize(bytes) { const text = formatBytes(bytes); if (bytes >= 5 * 1024) return { text, style: ansi.yellow }; if (bytes >= 1024) return { text, style: ansi.white }; return { text, style: ansi.green }; } /** Generate a local-only report (no base comparison). */ function generateLocalReport(current) { const groups = groupByPackage(current); const overviewRows = [['Package', 'Size']]; let grandTotal = 0; const pkgsWithSubs = []; for (const [pkg, entries] of groups) { const rootEntries = entries.filter((e) => e.type === 'root'); const subEntries = entries.filter((e) => e.type === 'subpath'); const pkgTotal = rootEntries.reduce((s, e) => s + e.size, 0) + subEntries.reduce((s, e) => s + e.size, 0); grandTotal += pkgTotal; overviewRows.push([ { text: `@videojs/${pkg}`, style: ansi.bold }, colorSize(pkgTotal), ]); if (entries.length > 1) { pkgsWithSubs.push({ pkg, rootEntries, subEntries, pkgTotal }); } } const lines = []; lines.push(''); lines.push(printTable(overviewRows)); lines.push(''); lines.push(ansi.bold(`Total: ${formatBytes(grandTotal)}`)); if (pkgsWithSubs.length > 0) { for (const { pkg, rootEntries, subEntries, pkgTotal } of pkgsWithSubs) { lines.push(''); lines.push(ansi.bold(`@videojs/${pkg}`)); const rows = [['Entry', 'Size']]; for (const entry of [...rootEntries, ...subEntries]) { const displayName = entry.name.replace(`@videojs/${pkg}`, '') || '.'; const label = displayName === '.' ? displayName : `.${displayName}`; rows.push([ { text: label, style: ansi.cyan }, colorSize(entry.size), ]); } rows.push([ { text: 'total', style: ansi.bold }, { text: formatBytes(pkgTotal), style: ansi.bold }, ]); lines.push(printTable(rows)); } } lines.push(''); lines.push(ansi.dim('Sizes are minified + brotli.')); lines.push(''); return lines.join('\n'); } function main() { const args = process.argv.slice(2); const prIndex = args.indexOf('--pr'); const baseIndex = args.indexOf('--base'); if (prIndex === -1) { // Read from stdin (piped from bundle-size.js) const input = readFileSync('/dev/stdin', 'utf8'); const current = JSON.parse(input); console.log(generateLocalReport(current)); return; } const prPath = args[prIndex + 1]; const current = JSON.parse(readFileSync(prPath, 'utf8')); if (baseIndex !== -1) { const basePath = args[baseIndex + 1]; const base = JSON.parse(readFileSync(basePath, 'utf8')); console.log(generateComparisonReport(current, base)); } else { console.log(generateLocalReport(current)); } } main();