mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 21:57:29 +00:00
476 lines
14 KiB
JavaScript
476 lines
14 KiB
JavaScript
/**
|
||
* Generates a 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 local report showing current sizes.
|
||
* When --base is provided, generates a comparison report with diffs.
|
||
*
|
||
* Reads JSON arrays of { name, size, type, category?, format, standaloneSize? }
|
||
* entries produced by bundle-size.js.
|
||
*/
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 = previous === 0 ? '∞' : Math.abs((diff / previous) * 100).toFixed(1);
|
||
return {
|
||
bytes: `${sign}${formatBytes(Math.abs(diff))}`,
|
||
pct: previous === 0 ? `${sign}∞%` : `${sign}${pct}%`,
|
||
};
|
||
}
|
||
|
||
function statusIcon(current, previous) {
|
||
if (previous === undefined) return '🆕';
|
||
const diff = current - previous;
|
||
if (diff === 0) return '✅';
|
||
if (diff < 0) return '🔽';
|
||
if (previous === 0) return '🔴';
|
||
const pct = (diff / previous) * 100;
|
||
return pct > 10 ? '🔴' : '🔺';
|
||
}
|
||
|
||
/** Preferred display order for packages. Unlisted packages sort to the end. */
|
||
const PACKAGE_ORDER = ['html', 'react', 'core', 'element', 'store', 'utils'];
|
||
|
||
/** Group entries by package: @videojs/html/ui/x → html */
|
||
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);
|
||
}
|
||
|
||
const sorted = new Map();
|
||
for (const pkg of PACKAGE_ORDER) {
|
||
if (groups.has(pkg)) sorted.set(pkg, groups.get(pkg));
|
||
}
|
||
for (const [pkg, entries] of groups) {
|
||
if (!sorted.has(pkg)) sorted.set(pkg, entries);
|
||
}
|
||
return sorted;
|
||
}
|
||
|
||
/** Display label for an entry relative to its package. */
|
||
function entryLabel(entryName, pkg) {
|
||
const subpath = entryName.replace(`@videojs/${pkg}`, '');
|
||
return subpath === '' ? '`.`' : `\`${subpath}\``;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Category breakdown (size-only, collapsed <details>)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const CATEGORY_ORDER = [
|
||
'preset',
|
||
'media',
|
||
'player',
|
||
'skin',
|
||
'ui',
|
||
'feature',
|
||
];
|
||
|
||
const CATEGORY_LABELS = {
|
||
preset: 'Presets',
|
||
media: 'Media',
|
||
player: 'Players',
|
||
skin: 'Skins',
|
||
ui: 'UI Components',
|
||
feature: 'Features',
|
||
};
|
||
|
||
function generateCategoryBreakdowns(entries, pkg) {
|
||
const byCategory = new Map();
|
||
for (const entry of entries) {
|
||
const cat = entry.category;
|
||
if (!cat) continue;
|
||
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
||
byCategory.get(cat).push(entry);
|
||
}
|
||
|
||
const lines = [];
|
||
|
||
for (const cat of CATEGORY_ORDER) {
|
||
const catEntries = byCategory.get(cat);
|
||
if (!catEntries || catEntries.length === 0) continue;
|
||
|
||
const label = CATEGORY_LABELS[cat] ?? cat;
|
||
const isSkin = cat === 'skin';
|
||
|
||
lines.push('<details>');
|
||
lines.push(`<summary><b>${label} (${catEntries.length})</b></summary>`);
|
||
lines.push('');
|
||
|
||
if (isSkin) {
|
||
lines.push('| Entry | Type | Size |');
|
||
lines.push('|---|---|--:|');
|
||
} else {
|
||
lines.push('| Entry | Size |');
|
||
lines.push('|---|--:|');
|
||
}
|
||
|
||
for (const entry of catEntries) {
|
||
const el = entryLabel(entry.name, pkg);
|
||
const fmt = entry.format ?? 'js';
|
||
if (isSkin) {
|
||
lines.push(`| ${el} | ${fmt} | ${formatBytes(entry.size)} |`);
|
||
} else {
|
||
lines.push(`| ${el} | ${formatBytes(entry.size)} |`);
|
||
}
|
||
}
|
||
|
||
if (cat === 'ui') {
|
||
lines.push('');
|
||
lines.push('*Sizes are marginal over the root entry point.*');
|
||
}
|
||
|
||
lines.push('</details>');
|
||
lines.push('');
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
/** Flat size-only breakdown for packages without categories. */
|
||
function generateFlatBreakdown(entries, pkg) {
|
||
const lines = [];
|
||
|
||
lines.push('<details>');
|
||
lines.push(`<summary><b>Entries (${entries.length})</b></summary>`);
|
||
lines.push('');
|
||
lines.push('| Entry | Size |');
|
||
lines.push('|---|--:|');
|
||
|
||
for (const entry of entries) {
|
||
const el = entryLabel(entry.name, pkg);
|
||
lines.push(`| ${el} | ${formatBytes(entry.size)} |`);
|
||
}
|
||
|
||
lines.push('');
|
||
lines.push('</details>');
|
||
lines.push('');
|
||
|
||
return lines;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Comparison report (CI — PR vs base)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function generateComparisonReport(current, base) {
|
||
const baseMap = Object.fromEntries(base.map((e) => [e.name, e.size]));
|
||
const currentMap = Object.fromEntries(current.map((e) => [e.name, e.size]));
|
||
|
||
// Standalone size lookups — used to gate UI component diffs.
|
||
// UI marginal sizes shift when root content changes (brotli compression is
|
||
// non-linear), so we compare standalone sizes to decide IF a component
|
||
// actually changed, then display the marginal diff.
|
||
const baseStandaloneMap = Object.fromEntries(
|
||
base.map((e) => [e.name, e.standaloneSize ?? e.size]),
|
||
);
|
||
const currentStandaloneMap = Object.fromEntries(
|
||
current.map((e) => [e.name, e.standaloneSize ?? e.size]),
|
||
);
|
||
|
||
const groups = groupByPackage(current);
|
||
|
||
// Build a set of all base entries grouped by package so we can detect removals
|
||
const baseGroups = groupByPackage(base);
|
||
|
||
const lines = [];
|
||
const pkgIcons = {
|
||
core: '🧩',
|
||
element: '🏷️',
|
||
html: '🎨',
|
||
react: '⚛️',
|
||
store: '📦',
|
||
utils: '🔧',
|
||
};
|
||
|
||
lines.push('<!-- bundle-size-report -->');
|
||
lines.push('# 📦 Bundle Size Report');
|
||
lines.push('');
|
||
|
||
// Collect all package names from both sides
|
||
const allPackages = new Set([...groups.keys(), ...baseGroups.keys()]);
|
||
const orderedPackages = [];
|
||
for (const pkg of PACKAGE_ORDER) {
|
||
if (allPackages.has(pkg)) orderedPackages.push(pkg);
|
||
}
|
||
for (const pkg of allPackages) {
|
||
if (!orderedPackages.includes(pkg)) orderedPackages.push(pkg);
|
||
}
|
||
|
||
for (const pkg of orderedPackages) {
|
||
const entries = groups.get(pkg) ?? [];
|
||
const baseEntries = baseGroups.get(pkg) ?? [];
|
||
const pkgIcon = pkgIcons[pkg] ?? '📦';
|
||
|
||
// Entries with meaningful size changes (>300 B threshold).
|
||
// For UI components, gate on standalone size to filter out phantom diffs
|
||
// caused by brotli compression shifts when root content changes.
|
||
const changed = entries.filter((e) => {
|
||
const prevStandalone = baseStandaloneMap[e.name];
|
||
// New entry — always surface
|
||
if (prevStandalone === undefined) return true;
|
||
const curStandalone = currentStandaloneMap[e.name];
|
||
return Math.abs(curStandalone - prevStandalone) > 300;
|
||
});
|
||
|
||
// Entries that existed in base but are missing in PR (removed)
|
||
const removed = baseEntries.filter((e) => currentMap[e.name] === undefined);
|
||
|
||
const hasChanges = changed.length > 0 || removed.length > 0;
|
||
|
||
// Category breakdowns for packages with categories (html, react)
|
||
const hasCategories = entries.some((e) => e.category);
|
||
const breakdownLines = hasCategories
|
||
? generateCategoryBreakdowns(entries, pkg)
|
||
: entries.length > 1
|
||
? generateFlatBreakdown(entries, pkg)
|
||
: [];
|
||
|
||
if (hasChanges) {
|
||
lines.push(`## ${pkgIcon} @videojs/${pkg}`);
|
||
lines.push('');
|
||
|
||
lines.push('| Path | Base | PR | Diff | % | |');
|
||
lines.push('|---|--:|--:|--:|--:|:-:|');
|
||
|
||
for (const entry of changed) {
|
||
const el = entryLabel(entry.name, pkg);
|
||
const prev = baseMap[entry.name];
|
||
const d = formatDelta(entry.size, prev);
|
||
const status = statusIcon(entry.size, prev);
|
||
const baseSize = prev !== undefined ? formatBytes(prev) : '—';
|
||
lines.push(
|
||
`| ${el} | ${baseSize} | ${formatBytes(entry.size)} | ${d.bytes} | ${d.pct} | ${status} |`,
|
||
);
|
||
}
|
||
|
||
for (const entry of removed) {
|
||
const el = entryLabel(entry.name, pkg);
|
||
lines.push(
|
||
`| ${el} | ${formatBytes(entry.size)} | — | −${formatBytes(entry.size)} | −100% | 🗑️ |`,
|
||
);
|
||
}
|
||
|
||
lines.push('');
|
||
lines.push(...breakdownLines);
|
||
} else {
|
||
lines.push('<details>');
|
||
lines.push(`<summary><b>${pkgIcon} @videojs/${pkg}</b> — no changes</summary>`);
|
||
lines.push('');
|
||
lines.push(...breakdownLines);
|
||
lines.push('</details>');
|
||
lines.push('');
|
||
}
|
||
}
|
||
|
||
// Footer
|
||
lines.push('---');
|
||
lines.push('');
|
||
lines.push('<details>');
|
||
lines.push('<summary>ℹ️ How to interpret</summary>');
|
||
lines.push('');
|
||
lines.push('All sizes are standalone totals (minified + brotli).');
|
||
lines.push('');
|
||
lines.push('| Icon | Meaning |');
|
||
lines.push('|---|---|');
|
||
lines.push('| ✅ | No change |');
|
||
lines.push('| 🔺 | Increased ≤ 10% |');
|
||
lines.push('| 🔴 | Increased > 10% |');
|
||
lines.push('| 🔽 | Decreased |');
|
||
lines.push('| 🆕 | New (no baseline) |');
|
||
lines.push('');
|
||
lines.push('Run `pnpm size` locally to check current sizes.');
|
||
lines.push('</details>');
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Local report (terminal — ANSI colored)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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`,
|
||
};
|
||
|
||
/**
|
||
* 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 an ansi function.
|
||
* 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 out = [];
|
||
|
||
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]);
|
||
return r === 0 ? ansi.dim(padded) : style(cell)(padded);
|
||
});
|
||
out.push(` ${cells.join(ansi.dim(' │ '))} `);
|
||
if (r === 0) out.push(sep);
|
||
}
|
||
|
||
return out.join('\n');
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
function generateLocalReport(current) {
|
||
const groups = groupByPackage(current);
|
||
const lines = [];
|
||
|
||
for (const [pkg, entries] of groups) {
|
||
lines.push('');
|
||
lines.push(ansi.bold(`@videojs/${pkg}`));
|
||
|
||
const hasCategories = entries.some((e) => e.category);
|
||
|
||
if (hasCategories) {
|
||
const byCategory = new Map();
|
||
for (const entry of entries) {
|
||
const cat = entry.category;
|
||
if (!cat) continue;
|
||
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
||
byCategory.get(cat).push(entry);
|
||
}
|
||
|
||
for (const cat of CATEGORY_ORDER) {
|
||
const catEntries = byCategory.get(cat);
|
||
if (!catEntries || catEntries.length === 0) continue;
|
||
|
||
const label = CATEGORY_LABELS[cat] ?? cat;
|
||
const isSkin = cat === 'skin';
|
||
|
||
lines.push('');
|
||
lines.push(` ${ansi.dim(label)}`);
|
||
|
||
const header = isSkin
|
||
? ['Entry', 'Type', 'Size']
|
||
: ['Entry', 'Size'];
|
||
const rows = [header];
|
||
|
||
for (const entry of catEntries) {
|
||
const subpath =
|
||
entry.name.replace(`@videojs/${pkg}`, '') || '.';
|
||
const fmt = entry.format ?? 'js';
|
||
if (isSkin) {
|
||
rows.push([
|
||
{ text: subpath, style: ansi.cyan },
|
||
{ text: fmt, style: ansi.dim },
|
||
colorSize(entry.size),
|
||
]);
|
||
} else {
|
||
rows.push([
|
||
{ text: subpath, style: ansi.cyan },
|
||
colorSize(entry.size),
|
||
]);
|
||
}
|
||
}
|
||
|
||
lines.push(printTable(rows));
|
||
}
|
||
} else {
|
||
const rows = [['Entry', 'Size']];
|
||
for (const entry of entries) {
|
||
const subpath =
|
||
entry.name.replace(`@videojs/${pkg}`, '') || '.';
|
||
rows.push([
|
||
{ text: subpath, style: ansi.cyan },
|
||
colorSize(entry.size),
|
||
]);
|
||
}
|
||
lines.push(printTable(rows));
|
||
}
|
||
}
|
||
|
||
lines.push('');
|
||
lines.push(ansi.dim('Sizes are minified + brotli.'));
|
||
lines.push('');
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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();
|