mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 21:57:29 +00:00
377 lines
11 KiB
JavaScript
377 lines
11 KiB
JavaScript
/**
|
||
* 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>');
|
||
details.push(`<summary><code>@videojs/${pkg}</code></summary>`);
|
||
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>');
|
||
details.push('');
|
||
}
|
||
}
|
||
|
||
const grandDelta = formatDelta(grandTotalCurrent, grandTotalBase);
|
||
|
||
const marker = '<!-- bundle-size-report -->';
|
||
return [
|
||
marker,
|
||
'### 📦 Bundle Size Report',
|
||
'',
|
||
...overview,
|
||
'',
|
||
`**Total: ${formatBytes(grandTotalCurrent)}**${grandTotalBase ? ` · ${grandDelta.bytes} · ${grandDelta.pct}` : ''}`,
|
||
'',
|
||
'---',
|
||
'',
|
||
...details,
|
||
'---',
|
||
'',
|
||
'<details>',
|
||
'<summary>ℹ️ How to interpret</summary>',
|
||
'',
|
||
'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.',
|
||
'</details>',
|
||
].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();
|