mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 10:23:32 +00:00
fix(ci): rework bundle size report (#745)
This commit is contained in:
@@ -1,26 +1,21 @@
|
||||
/**
|
||||
* Generates a markdown bundle size report from measurement JSON data.
|
||||
* 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 report showing current sizes only (no diff).
|
||||
* When --base is provided, generates a comparison report with diffs and status icons.
|
||||
* 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 } entries produced by bundle-size.js.
|
||||
* Reads JSON arrays of { name, size, type, category?, format } 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`,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -32,10 +27,10 @@ function formatDelta(current, previous) {
|
||||
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);
|
||||
const pct = previous === 0 ? '∞' : Math.abs((diff / previous) * 100).toFixed(1);
|
||||
return {
|
||||
bytes: `${sign}${formatBytes(Math.abs(diff))}`,
|
||||
pct: `${sign}${pct}%`,
|
||||
pct: previous === 0 ? `${sign}∞%` : `${sign}${pct}%`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,19 +39,15 @@ function statusIcon(current, previous) {
|
||||
const diff = current - previous;
|
||||
if (diff === 0) return '✅';
|
||||
if (diff < 0) return '🔽';
|
||||
if (previous === 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);
|
||||
}
|
||||
/** 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/utils/* -> utils, @videojs/store/* -> store */
|
||||
/** Group entries by package: @videojs/html/ui/x → html */
|
||||
function groupByPackage(entries) {
|
||||
const groups = new Map();
|
||||
for (const entry of entries) {
|
||||
@@ -65,179 +56,225 @@ function groupByPackage(entries) {
|
||||
if (!groups.has(pkg)) groups.set(pkg, []);
|
||||
groups.get(pkg).push(entry);
|
||||
}
|
||||
return groups;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function computePackageData(groups, baseMap) {
|
||||
const pkgData = [];
|
||||
let grandTotalCurrent = 0;
|
||||
let grandTotalBase = 0;
|
||||
/** Display label for an entry relative to its package. */
|
||||
function entryLabel(entryName, pkg) {
|
||||
const subpath = entryName.replace(`@videojs/${pkg}`, '');
|
||||
return subpath === '' ? '`.`' : `\`${subpath}\``;
|
||||
}
|
||||
|
||||
for (const [pkg, entries] of groups) {
|
||||
const rootEntries = entries.filter((e) => e.type === 'root');
|
||||
const subEntries = entries.filter((e) => e.type === 'subpath');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category breakdown (size-only, collapsed <details>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pkgTotalCurrent =
|
||||
rootEntries.reduce((s, e) => s + e.size, 0) +
|
||||
subEntries.reduce((s, e) => s + e.size, 0);
|
||||
const CATEGORY_ORDER = [
|
||||
'preset',
|
||||
'media',
|
||||
'player',
|
||||
'skin',
|
||||
'ui',
|
||||
'feature',
|
||||
];
|
||||
|
||||
const pkgTotalBase =
|
||||
rootEntries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0) +
|
||||
subEntries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0);
|
||||
const CATEGORY_LABELS = {
|
||||
preset: 'Presets',
|
||||
media: 'Media',
|
||||
player: 'Players',
|
||||
skin: 'Skins',
|
||||
ui: 'UI Components',
|
||||
feature: 'Features',
|
||||
};
|
||||
|
||||
const hasBase = entries.some((e) => baseMap[e.name] !== undefined);
|
||||
grandTotalCurrent += pkgTotalCurrent;
|
||||
grandTotalBase += pkgTotalBase;
|
||||
|
||||
pkgData.push({
|
||||
pkg,
|
||||
entries,
|
||||
rootEntries,
|
||||
subEntries,
|
||||
pkgTotalCurrent,
|
||||
pkgTotalBase,
|
||||
hasBase,
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
return { pkgData, grandTotalCurrent, grandTotalBase };
|
||||
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;
|
||||
}
|
||||
|
||||
/** Generate a comparison report (PR vs base). */
|
||||
/** 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 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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const lines = [];
|
||||
const pkgIcons = {
|
||||
core: '🧩',
|
||||
element: '🏷️',
|
||||
html: '🎨',
|
||||
react: '⚛️',
|
||||
store: '📦',
|
||||
utils: '🔧',
|
||||
};
|
||||
|
||||
// Overview table
|
||||
const overview = [];
|
||||
overview.push('| Package | Size | Diff | | % | |');
|
||||
overview.push('|---|--:|--:|---|--:|:-:|');
|
||||
lines.push('<!-- bundle-size-report -->');
|
||||
lines.push('# 📦 Bundle Size Report');
|
||||
lines.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} |`,
|
||||
);
|
||||
}
|
||||
for (const [pkg, entries] of groups) {
|
||||
const pkgIcon = pkgIcons[pkg] ?? '📦';
|
||||
lines.push(`## ${pkgIcon} @videojs/${pkg}`);
|
||||
lines.push('');
|
||||
|
||||
// Detail sections — only for packages with multiple entries
|
||||
const details = [];
|
||||
const pkgsWithSubs = pkgData.filter((p) => p.entries.length > 1);
|
||||
// Only show entries with a meaningful size change (>300 B, must exist in both)
|
||||
const changed = entries.filter((e) => {
|
||||
const prev = baseMap[e.name];
|
||||
if (prev === undefined) return false;
|
||||
return Math.abs(e.size - prev) > 300;
|
||||
});
|
||||
|
||||
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('');
|
||||
if (changed.length === 0) {
|
||||
lines.push('(no changes)');
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('| Path | Base | PR | Diff | % | |');
|
||||
lines.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}`;
|
||||
for (const entry of changed) {
|
||||
const el = entryLabel(entry.name, pkg);
|
||||
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)} |`,
|
||||
const status = statusIcon(entry.size, prev);
|
||||
const baseSize = prev !== undefined ? formatBytes(prev) : '—';
|
||||
lines.push(
|
||||
`| ${el} | ${baseSize} | ${formatBytes(entry.size)} | ${d.bytes} | ${d.pct} | ${status} |`,
|
||||
);
|
||||
}
|
||||
|
||||
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 : ''}** | |`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
details.push('');
|
||||
details.push('</details>');
|
||||
details.push('');
|
||||
// Category breakdowns for packages with categories (html, react)
|
||||
const hasCategories = entries.some((e) => e.category);
|
||||
if (hasCategories) {
|
||||
lines.push(...generateCategoryBreakdowns(entries, pkg));
|
||||
} else if (entries.length > 1) {
|
||||
// Flat breakdown for other packages with multiple entries
|
||||
lines.push(...generateFlatBreakdown(entries, pkg));
|
||||
}
|
||||
}
|
||||
|
||||
const grandDelta = formatDelta(grandTotalCurrent, grandTotalBase);
|
||||
// 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>');
|
||||
|
||||
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');
|
||||
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 a ansi chain (e.g. ansi.bold).
|
||||
* Alignment and padding use the plain text width; ANSI codes are ignored.
|
||||
*
|
||||
* `{ text, style }` objects where `style` is an ansi function.
|
||||
* The first row is treated as a dim header.
|
||||
*/
|
||||
function printTable(rows) {
|
||||
@@ -258,24 +295,22 @@ function printTable(rows) {
|
||||
const sep = ansi.dim(
|
||||
`─${widths.map((w) => '─'.repeat(w)).join('─┼─')}─`,
|
||||
);
|
||||
const lines = [];
|
||||
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]);
|
||||
// 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);
|
||||
out.push(` ${cells.join(ansi.dim(' │ '))} `);
|
||||
if (r === 0) out.push(sep);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
return out.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 };
|
||||
@@ -283,59 +318,70 @@ function colorSize(bytes) {
|
||||
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 = [];
|
||||
const lines = [];
|
||||
|
||||
for (const [pkg, entries] of groups) {
|
||||
const rootEntries = entries.filter((e) => e.type === 'root');
|
||||
const subEntries = entries.filter((e) => e.type === 'subpath');
|
||||
lines.push('');
|
||||
lines.push(ansi.bold(`@videojs/${pkg}`));
|
||||
|
||||
const pkgTotal =
|
||||
rootEntries.reduce((s, e) => s + e.size, 0) +
|
||||
subEntries.reduce((s, e) => s + e.size, 0);
|
||||
const hasCategories = entries.some((e) => e.category);
|
||||
|
||||
grandTotal += pkgTotal;
|
||||
overviewRows.push([
|
||||
{ text: `@videojs/${pkg}`, style: ansi.bold },
|
||||
colorSize(pkgTotal),
|
||||
]);
|
||||
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);
|
||||
}
|
||||
|
||||
if (entries.length > 1) {
|
||||
pkgsWithSubs.push({ pkg, rootEntries, subEntries, pkgTotal });
|
||||
}
|
||||
}
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
const catEntries = byCategory.get(cat);
|
||||
if (!catEntries || catEntries.length === 0) continue;
|
||||
|
||||
const lines = [];
|
||||
lines.push('');
|
||||
lines.push(printTable(overviewRows));
|
||||
lines.push('');
|
||||
lines.push(ansi.bold(`Total: ${formatBytes(grandTotal)}`));
|
||||
const label = CATEGORY_LABELS[cat] ?? cat;
|
||||
const isSkin = cat === 'skin';
|
||||
|
||||
if (pkgsWithSubs.length > 0) {
|
||||
for (const { pkg, rootEntries, subEntries, pkgTotal } of pkgsWithSubs) {
|
||||
lines.push('');
|
||||
lines.push(ansi.bold(`@videojs/${pkg}`));
|
||||
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 [...rootEntries, ...subEntries]) {
|
||||
const displayName =
|
||||
for (const entry of entries) {
|
||||
const subpath =
|
||||
entry.name.replace(`@videojs/${pkg}`, '') || '.';
|
||||
const label = displayName === '.' ? displayName : `.${displayName}`;
|
||||
rows.push([
|
||||
{ text: label, style: ansi.cyan },
|
||||
{ text: subpath, style: ansi.cyan },
|
||||
colorSize(entry.size),
|
||||
]);
|
||||
}
|
||||
rows.push([
|
||||
{ text: 'total', style: ansi.bold },
|
||||
{ text: formatBytes(pkgTotal), style: ansi.bold },
|
||||
]);
|
||||
|
||||
lines.push(printTable(rows));
|
||||
}
|
||||
}
|
||||
@@ -347,6 +393,10 @@ function generateLocalReport(current) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
|
||||
+379
-21
@@ -1,34 +1,156 @@
|
||||
/**
|
||||
* Measures bundle sizes for all packages, computing marginal subpath costs.
|
||||
* Measures bundle sizes for all packages.
|
||||
*
|
||||
* Auto-discovers packages from `packages/`, reads their `exports` field to find
|
||||
* entry points, and externalizes `peerDependencies`.
|
||||
*
|
||||
* For packages with subpaths, each subpath is bundled together with the root
|
||||
* entry point. The marginal cost is: (root + subpath) - root. This captures
|
||||
* shared minification and compression, avoiding the inflated totals you get
|
||||
* from summing independently-measured subpaths.
|
||||
* All sizes are standalone totals (minified + brotli).
|
||||
*
|
||||
* Wildcard exports (e.g., `./ui/*`, `./media/⁕/index.js`) are resolved to
|
||||
* actual files on disk. Supports both file-level (`*.js`) and directory-level
|
||||
* (`⁕/index.js`) wildcards.
|
||||
*
|
||||
* CSS files are minified with esbuild then brotli-compressed.
|
||||
*
|
||||
* Each entry includes a `category` field for grouped reporting in html/react:
|
||||
* preset, media, player, skin, ui, or feature.
|
||||
*
|
||||
* Usage: node .github/scripts/bundle-size.js [--json output.json]
|
||||
*/
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { build, transform } from 'esbuild';
|
||||
import { brotliCompressSync, constants } from 'node:zlib';
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
} from 'node:fs';
|
||||
import { resolve, dirname, join, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '../..');
|
||||
const PACKAGES_DIR = join(ROOT, 'packages');
|
||||
|
||||
const SKIP_PACKAGES = new Set(['sandbox', '__tech-preview__', 'react-native']);
|
||||
const SKIP_PACKAGES = new Set([
|
||||
'sandbox',
|
||||
'__tech-preview__',
|
||||
'react-native',
|
||||
'skins',
|
||||
'icons',
|
||||
]);
|
||||
|
||||
/** Packages that get categorized breakdowns in the report. */
|
||||
const CATEGORIZED_PACKAGES = new Set(['html', 'react']);
|
||||
|
||||
/** UI compound component parts — excluded from the report. */
|
||||
const UI_PARTS = new Set([
|
||||
'controls-group',
|
||||
'slider-buffer',
|
||||
'slider-fill',
|
||||
'slider-thumb',
|
||||
'slider-thumbnail',
|
||||
'slider-track',
|
||||
'slider-value',
|
||||
'time-group',
|
||||
'time-separator',
|
||||
'tooltip-group',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Preset virtual bundle definitions.
|
||||
*
|
||||
* Each preset combines skin + player (HTML) or skin + media + features (React)
|
||||
* into a single virtual entry to measure realistic per-skin configuration costs.
|
||||
*
|
||||
* @type {Array<{ label: string, preset: string, skin: string, hls: boolean }>}
|
||||
*/
|
||||
const PRESET_CONFIGS = [
|
||||
{ label: '/video (default)', preset: 'video', skin: 'skin', hls: false },
|
||||
{ label: '/video (default + hls)', preset: 'video', skin: 'skin', hls: true },
|
||||
{ label: '/video (minimal)', preset: 'video', skin: 'minimal-skin', hls: false },
|
||||
{ label: '/video (minimal + hls)', preset: 'video', skin: 'minimal-skin', hls: true },
|
||||
{ label: '/audio (default)', preset: 'audio', skin: 'skin', hls: false },
|
||||
{ label: '/audio (minimal)', preset: 'audio', skin: 'minimal-skin', hls: false },
|
||||
{ label: '/background', preset: 'background', skin: 'skin', hls: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Export name lookup tables for preset virtual bundles.
|
||||
*
|
||||
* Each key is `{preset}/{variant}` where variant is 'skin', 'player', 'media',
|
||||
* 'hls-media', or 'features'. Values are `{ path, name }` where path is relative
|
||||
* to the package dist/default/ directory and name is the exported identifier.
|
||||
*/
|
||||
const PRESET_EXPORTS = {
|
||||
html: {
|
||||
'video/skin': { path: 'define/video/skin.js', name: 'VideoSkinElement' },
|
||||
'video/minimal-skin': { path: 'define/video/minimal-skin.js', name: 'MinimalVideoSkinElement' },
|
||||
'video/player': { path: 'define/video/player.js', name: 'VideoPlayerElement' },
|
||||
'audio/skin': { path: 'define/audio/skin.js', name: 'AudioSkinElement' },
|
||||
'audio/minimal-skin': { path: 'define/audio/minimal-skin.js', name: 'MinimalAudioSkinElement' },
|
||||
'audio/player': { path: 'define/audio/player.js', name: 'AudioPlayerElement' },
|
||||
'background/skin': { path: 'define/background/skin.js', name: 'BackgroundVideoSkinElement' },
|
||||
'background/player': { path: 'define/background/player.js', name: 'BackgroundVideoPlayerElement' },
|
||||
'hls-media': { path: 'define/media/hls-video.js', name: 'HlsVideoElement' },
|
||||
},
|
||||
react: {
|
||||
'video/skin': { path: 'presets/video/skin.js', name: 'VideoSkin' },
|
||||
'video/minimal-skin': { path: 'presets/video/minimal-skin.js', name: 'MinimalVideoSkin' },
|
||||
'video/media': { path: 'media/video.js', name: 'Video' },
|
||||
'audio/skin': { path: 'presets/audio/skin.js', name: 'AudioSkin' },
|
||||
'audio/minimal-skin': { path: 'presets/audio/minimal-skin.js', name: 'MinimalAudioSkin' },
|
||||
'audio/media': { path: 'media/audio.js', name: 'Audio' },
|
||||
'background/skin': { path: 'presets/background/skin.js', name: 'BackgroundVideoSkin' },
|
||||
'background/media': { path: 'media/background-video/index.js', name: 'BackgroundVideo' },
|
||||
'hls-media': { path: 'media/hls-video/index.js', name: 'HlsVideo' },
|
||||
'video/features': { path: '../../../core/dist/default/dom/store/features/presets.js', name: 'videoFeatures' },
|
||||
'audio/features': { path: '../../../core/dist/default/dom/store/features/presets.js', name: 'audioFeatures' },
|
||||
'background/features': { path: '../../../core/dist/default/dom/store/features/presets.js', name: 'backgroundFeatures' },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build virtual entry source code for a preset configuration.
|
||||
*
|
||||
* Returns an import/export string that re-exports the skin, player/media,
|
||||
* features, and optional HLS media for the given preset. Returns null if
|
||||
* any required file is missing on disk.
|
||||
*/
|
||||
function buildPresetEntry(pkgShortName, config, distDir) {
|
||||
const table = PRESET_EXPORTS[pkgShortName];
|
||||
if (!table) return null;
|
||||
|
||||
const lines = [];
|
||||
|
||||
function addExport(key) {
|
||||
const entry = table[key];
|
||||
// Key not in lookup → not applicable for this package type (e.g., HTML
|
||||
// has no media/features entries). Skip without aborting.
|
||||
if (!entry) return true;
|
||||
const fullPath = resolve(distDir, entry.path);
|
||||
if (!existsSync(fullPath)) return false;
|
||||
lines.push(`export { ${entry.name} } from './${entry.path}';`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!addExport(`${config.preset}/${config.skin}`)) return null;
|
||||
if (!addExport(`${config.preset}/player`)) return null;
|
||||
if (!addExport(`${config.preset}/media`)) return null;
|
||||
if (!addExport(`${config.preset}/features`)) return null;
|
||||
if (config.hls && !addExport('hls-media')) return null;
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} SizeEntry
|
||||
* @property {string} name
|
||||
* @property {number} size - Root: brotli size. Subpath: marginal cost over root.
|
||||
* @property {number} size
|
||||
* @property {'root' | 'subpath'} type
|
||||
* @property {string} [category] - preset, media, player, skin, ui, feature (only for html/react)
|
||||
* @property {'js' | 'css'} format
|
||||
*/
|
||||
|
||||
/** Bundle entry points with esbuild and return the minified + brotli size. */
|
||||
@@ -40,7 +162,6 @@ async function measure(entryPoints, external = []) {
|
||||
treeShaking: true,
|
||||
format: 'esm',
|
||||
write: false,
|
||||
metafile: true,
|
||||
outdir: '/tmp/bundle-size-out',
|
||||
external,
|
||||
logLevel: 'silent',
|
||||
@@ -56,6 +177,42 @@ async function measure(entryPoints, external = []) {
|
||||
return compressed.length;
|
||||
}
|
||||
|
||||
/** Minify a CSS file with esbuild then brotli-compress it. */
|
||||
async function measureCSS(filePath) {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const result = await transform(content, { loader: 'css', minify: true });
|
||||
const compressed = brotliCompressSync(Buffer.from(result.code), {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
|
||||
},
|
||||
});
|
||||
return compressed.length;
|
||||
}
|
||||
|
||||
/** Bundle a virtual entry (source string) with esbuild and return the minified + brotli size. */
|
||||
async function measureVirtual(code, resolveDir, external = []) {
|
||||
const result = await build({
|
||||
stdin: { contents: code, resolveDir, loader: 'js' },
|
||||
bundle: true,
|
||||
minify: true,
|
||||
treeShaking: true,
|
||||
format: 'esm',
|
||||
write: false,
|
||||
outdir: '/tmp/bundle-size-out',
|
||||
external,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
const output = result.outputFiles.map((f) => f.text).join('');
|
||||
const compressed = brotliCompressSync(Buffer.from(output), {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
|
||||
},
|
||||
});
|
||||
|
||||
return compressed.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `default` condition from an export value.
|
||||
* Handles both `{ default: "./dist/..." }` objects and plain string values.
|
||||
@@ -68,6 +225,107 @@ function resolveDefault(exportValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize an entry by its full name.
|
||||
*
|
||||
* Returns a category string for html/react packages, undefined for other
|
||||
* packages (they use flat breakdowns), or '_skip' for internal entries
|
||||
* in categorized packages that should be excluded.
|
||||
*/
|
||||
function categorize(name) {
|
||||
const match = name.match(/^@videojs\/([^/]+)(\/.*)?$/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const pkg = match[1];
|
||||
const subpath = match[2] ?? '';
|
||||
|
||||
if (!CATEGORIZED_PACKAGES.has(pkg)) return undefined;
|
||||
|
||||
// CSS files are always skin-related
|
||||
if (name.endsWith('.css')) return 'skin';
|
||||
|
||||
// Root and combined preset entries are skipped — the presets category is
|
||||
// populated by virtual bundles measured separately.
|
||||
if (subpath === '' || /^\/(video|audio|background)$/.test(subpath)) {
|
||||
return '_skip';
|
||||
}
|
||||
if (subpath.startsWith('/media/')) return 'media';
|
||||
if (subpath.startsWith('/ui/')) {
|
||||
// Skip compound component parts — only show main entries
|
||||
const uiName = subpath.slice('/ui/'.length);
|
||||
if (UI_PARTS.has(uiName)) return '_skip';
|
||||
return 'ui';
|
||||
}
|
||||
if (subpath.startsWith('/feature/')) return 'feature';
|
||||
|
||||
// Match skin entries but exclude internal utilities like skin-mixin
|
||||
if (/skin/i.test(subpath) && !/mixin/i.test(subpath)) return 'skin';
|
||||
|
||||
if (/\/player$/.test(subpath)) return 'player';
|
||||
|
||||
// Unrecognized entry in a categorized package (internal utility) — skip
|
||||
return '_skip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a wildcard export key to actual files on disk.
|
||||
*
|
||||
* Handles both file-level wildcards (e.g., `./ui/*.js` where `*` is a
|
||||
* filename stem) and directory-level wildcards (e.g., `./media/⁕/index.js`
|
||||
* where `*` is a directory name).
|
||||
*/
|
||||
function resolveWildcard(pkgDir, exportKey, exportValue) {
|
||||
const defaultPath = resolveDefault(exportValue);
|
||||
if (!defaultPath) return [];
|
||||
|
||||
const isCSS = exportKey.endsWith('.css');
|
||||
const fullPattern = resolve(pkgDir, defaultPath);
|
||||
|
||||
const starIdx = fullPattern.indexOf('*');
|
||||
if (starIdx === -1) return [];
|
||||
|
||||
// Find the directory that contains the wildcard
|
||||
const lastSlash = fullPattern.lastIndexOf('/', starIdx);
|
||||
const scanDir = fullPattern.slice(0, lastSlash);
|
||||
const prefix = fullPattern.slice(lastSlash + 1, starIdx);
|
||||
const suffix = fullPattern.slice(starIdx + 1);
|
||||
|
||||
if (!existsSync(scanDir)) return [];
|
||||
|
||||
// Directory pattern: `*/index.js` — * is a directory name
|
||||
// File pattern: `*.js` — * is a filename stem
|
||||
const isDirectoryPattern = suffix.startsWith('/');
|
||||
|
||||
return readdirSync(scanDir, { withFileTypes: true })
|
||||
.filter((d) => {
|
||||
if (!d.name.startsWith(prefix)) return false;
|
||||
return isDirectoryPattern ? d.isDirectory() : d.isFile();
|
||||
})
|
||||
.filter((d) => {
|
||||
if (isDirectoryPattern) return true;
|
||||
// For file patterns, the filename must end with the suffix
|
||||
return d.name.endsWith(suffix);
|
||||
})
|
||||
.map((d) => {
|
||||
const stem = isDirectoryPattern
|
||||
? d.name.slice(prefix.length)
|
||||
: d.name.slice(prefix.length, d.name.length - suffix.length);
|
||||
const fullPath = fullPattern.replace('*', stem);
|
||||
return { stem, fullPath };
|
||||
})
|
||||
.filter(({ fullPath }) => {
|
||||
if (!existsSync(fullPath)) return false;
|
||||
if (fullPath.includes('.test.')) return false;
|
||||
return readFileSync(fullPath, 'utf8').trim().length > 0;
|
||||
})
|
||||
.sort((a, b) => a.stem.localeCompare(b.stem))
|
||||
.map(({ stem, fullPath }) => ({
|
||||
exportKey: exportKey.replace('*', stem),
|
||||
path: fullPath,
|
||||
isCSS,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Discover packages and their entry points from the filesystem. */
|
||||
function discoverPackages() {
|
||||
const packages = [];
|
||||
@@ -91,8 +349,16 @@ function discoverPackages() {
|
||||
const subpaths = [];
|
||||
|
||||
for (const [key, value] of Object.entries(pkgJson.exports)) {
|
||||
// Skip wildcard exports (side-effect registration files)
|
||||
if (key.includes('*')) continue;
|
||||
if (key.includes('*')) {
|
||||
for (const r of resolveWildcard(pkgDir, key, value)) {
|
||||
subpaths.push({
|
||||
name: `${pkgName}${r.exportKey.slice(1)}`,
|
||||
path: r.path,
|
||||
isCSS: r.isCSS,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultPath = resolveDefault(value);
|
||||
if (!defaultPath) continue;
|
||||
@@ -112,12 +378,30 @@ function discoverPackages() {
|
||||
subpaths.push({
|
||||
name: `${pkgName}${key.slice(1)}`,
|
||||
path: absolutePath,
|
||||
isCSS: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// For categorized packages, scan dist/ui/ for components not already
|
||||
// discovered from exports (e.g., React tree-shakes UI from root).
|
||||
if (rootPath && CATEGORIZED_PACKAGES.has(dirName)) {
|
||||
const uiDir = join(dirname(rootPath), 'ui');
|
||||
if (existsSync(uiDir)) {
|
||||
const existing = new Set(subpaths.map((s) => s.name));
|
||||
for (const d of readdirSync(uiDir, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue;
|
||||
if (UI_PARTS.has(d.name)) continue;
|
||||
const indexPath = join(uiDir, d.name, 'index.js');
|
||||
if (!existsSync(indexPath)) continue;
|
||||
const name = `${pkgName}/ui/${d.name}`;
|
||||
if (existing.has(name)) continue;
|
||||
subpaths.push({ name, path: indexPath, isCSS: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rootPath) {
|
||||
// Package with a root export (and optional subpaths)
|
||||
packages.push({ name: pkgName, rootPath, external, subpaths });
|
||||
} else if (subpaths.length > 0) {
|
||||
// Package with only subpath exports (e.g., @videojs/utils)
|
||||
@@ -143,17 +427,91 @@ async function main() {
|
||||
const results = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
const isRootCSS = pkg.rootPath.endsWith('.css');
|
||||
|
||||
if (isRootCSS) {
|
||||
const cat = categorize(pkg.name);
|
||||
if (cat === '_skip') continue;
|
||||
|
||||
results.push({
|
||||
name: pkg.name,
|
||||
size: await measureCSS(pkg.rootPath),
|
||||
type: 'root',
|
||||
...(cat ? { category: cat } : {}),
|
||||
format: 'css',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rootCat = categorize(pkg.name);
|
||||
|
||||
// Always measure root — needed for UI marginal calculations even when
|
||||
// the root itself is excluded from results (categorized packages skip
|
||||
// root because presets are measured as virtual bundles instead).
|
||||
const rootSize = await measure([pkg.rootPath], pkg.external);
|
||||
results.push({ name: pkg.name, size: rootSize, type: 'root' });
|
||||
|
||||
if (rootCat !== '_skip') {
|
||||
results.push({
|
||||
name: pkg.name,
|
||||
size: rootSize,
|
||||
type: 'root',
|
||||
...(rootCat ? { category: rootCat } : {}),
|
||||
format: 'js',
|
||||
});
|
||||
}
|
||||
|
||||
for (const sub of pkg.subpaths) {
|
||||
const combinedSize = await measure(
|
||||
[pkg.rootPath, sub.path],
|
||||
pkg.external,
|
||||
);
|
||||
const marginal = combinedSize - rootSize;
|
||||
const cat = categorize(sub.name);
|
||||
if (cat === '_skip') continue;
|
||||
|
||||
results.push({ name: sub.name, size: marginal, type: 'subpath' });
|
||||
if (sub.isCSS) {
|
||||
results.push({
|
||||
name: sub.name,
|
||||
size: await measureCSS(sub.path),
|
||||
type: 'subpath',
|
||||
...(cat ? { category: cat } : {}),
|
||||
format: 'css',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// UI components are marginal over root (they share base element classes).
|
||||
// Everything else is standalone.
|
||||
let size;
|
||||
if (cat === 'ui') {
|
||||
const combined = await measure([pkg.rootPath, sub.path], pkg.external);
|
||||
size = combined - rootSize;
|
||||
} else {
|
||||
size = await measure([sub.path], pkg.external);
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: sub.name,
|
||||
size,
|
||||
type: 'subpath',
|
||||
...(cat ? { category: cat } : {}),
|
||||
format: 'js',
|
||||
});
|
||||
}
|
||||
|
||||
// Measure preset virtual bundles for categorized packages.
|
||||
const pkgShortName = pkg.name.replace('@videojs/', '');
|
||||
if (CATEGORIZED_PACKAGES.has(pkgShortName)) {
|
||||
const distDir = dirname(pkg.rootPath);
|
||||
|
||||
for (const config of PRESET_CONFIGS) {
|
||||
const code = buildPresetEntry(pkgShortName, config, distDir);
|
||||
if (!code) continue;
|
||||
|
||||
const size = await measureVirtual(code, distDir, pkg.external);
|
||||
results.push({
|
||||
name: `${pkg.name}${config.label}`,
|
||||
size,
|
||||
type: 'subpath',
|
||||
category: 'preset',
|
||||
format: 'js',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user