From 9f456f572f340f88b7e3e57316dd284e48631f78 Mon Sep 17 00:00:00 2001 From: rahim Date: Thu, 5 Mar 2026 17:11:11 -0800 Subject: [PATCH] fix(ci): rework bundle size report (#745) --- .github/scripts/bundle-size-report.js | 458 ++++++++++++++------------ .github/scripts/bundle-size.js | 400 ++++++++++++++++++++-- packages/react/package.json | 37 ++- 3 files changed, 665 insertions(+), 230 deletions(-) diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index d349c1c1..a9d3bf13 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -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
) +// --------------------------------------------------------------------------- - 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('
'); + lines.push(`${label} (${catEntries.length})`); + 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('
'); + 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('
'); + lines.push(`Entries (${entries.length})`); + 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('
'); + 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(''); + 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.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}`; + 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.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('
'); + lines.push('โ„น๏ธ How to interpret'); + 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('
'); - 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'); + 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); diff --git a/.github/scripts/bundle-size.js b/.github/scripts/bundle-size.js index f5c45d5f..589dd42c 100644 --- a/.github/scripts/bundle-size.js +++ b/.github/scripts/bundle-size.js @@ -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', + }); + } } } diff --git a/packages/react/package.json b/packages/react/package.json index b5f6b558..103e4d43 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,11 +27,38 @@ "development": "./dist/dev/media/*/index.js", "default": "./dist/default/media/*/index.js" }, - "./*.css": "./dist/default/presets/*.css", - "./*": { - "types": "./dist/dev/presets/*/index.d.ts", - "development": "./dist/dev/presets/*/index.js", - "default": "./dist/default/presets/*/index.js" + "./video": { + "types": "./dist/dev/presets/video/index.d.ts", + "development": "./dist/dev/presets/video/index.js", + "default": "./dist/default/presets/video/index.js" + }, + "./video/*.css": "./dist/default/presets/video/*.css", + "./video/*": { + "types": "./dist/dev/presets/video/*.d.ts", + "development": "./dist/dev/presets/video/*.js", + "default": "./dist/default/presets/video/*.js" + }, + "./audio": { + "types": "./dist/dev/presets/audio/index.d.ts", + "development": "./dist/dev/presets/audio/index.js", + "default": "./dist/default/presets/audio/index.js" + }, + "./audio/*.css": "./dist/default/presets/audio/*.css", + "./audio/*": { + "types": "./dist/dev/presets/audio/*.d.ts", + "development": "./dist/dev/presets/audio/*.js", + "default": "./dist/default/presets/audio/*.js" + }, + "./background": { + "types": "./dist/dev/presets/background/index.d.ts", + "development": "./dist/dev/presets/background/index.js", + "default": "./dist/default/presets/background/index.js" + }, + "./background/*.css": "./dist/default/presets/background/*.css", + "./background/*": { + "types": "./dist/dev/presets/background/*.d.ts", + "development": "./dist/dev/presets/background/*.js", + "default": "./dist/default/presets/background/*.js" } }, "scripts": {