afv-library/skills/experience-ui-bundle-performance-optimize/scripts/analyze-bundle.mjs
Claude 0934e64daa
feat: add experience-ui-bundle-performance-optimize skill
Adds a UI-focused skill that fills a measurable gap in the library: no
existing skill covers bundle-size analysis, code splitting, lazy route
loading, or render-waste diagnosis for React UI bundles (0 mentions of
vitest/web vitals/code splitting across all 87 skills).

The workflow is grounded in this repo's own samples: the Property
Management sample builds a single 1,104 kB entry chunk (Vite warns), and
applying the skill's route-splitting + manualChunks steps reduces it to a
50 kB entry with 9-16 kB lazy route chunks, verified end-to-end at
runtime. Includes scripts/analyze-bundle.mjs, a budget checker that exits
non-zero for CI gating, and documents the substring-matching pitfall where
id.includes('react') silently absorbs lucide-react into the react chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyvKMp1jFRs38dP1EYj458
2026-07-03 05:08:29 +00:00

126 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
/**
* UI Bundle build-output analyzer.
*
* Reads a Vite dist/ directory and reports every JS/CSS asset against the
* skill's size budgets, flagging what to split first.
*
* Usage: node analyze-bundle.mjs <path-to-dist> [--json]
*
* Exit code: 0 when all budgets pass, 1 when any budget is exceeded,
* 2 on usage/IO errors — so it can gate CI.
*/
import fs from 'node:fs';
import path from 'node:path';
const BUDGETS = {
entryChunkKb: 300, // index-*.js
anyChunkKb: 500,
routeChunkKb: 100, // lazy page chunks
cssTotalKb: 150,
};
const args = process.argv.slice(2);
const asJson = args.includes('--json');
const distDir = args.find((a) => !a.startsWith('--'));
if (!distDir) {
console.error('Usage: node analyze-bundle.mjs <path-to-dist> [--json]');
process.exit(2);
}
const assetsDir = path.join(distDir, 'assets');
if (!fs.existsSync(assetsDir)) {
console.error(`No assets directory at ${assetsDir} — run the app's \`npm run build\` first.`);
process.exit(2);
}
const kb = (bytes) => Math.round((bytes / 1024) * 100) / 100;
const assets = fs
.readdirSync(assetsDir)
.filter((f) => f.endsWith('.js') || f.endsWith('.css'))
.map((f) => {
const size = fs.statSync(path.join(assetsDir, f)).size;
const isJs = f.endsWith('.js');
const isEntry = isJs && /^index-/.test(f);
// Vite names lazy chunks after their source module (e.g. PropertySearch-<hash>.js)
const isRoute = isJs && !isEntry && /^[A-Z]/.test(f);
return { file: f, sizeKb: kb(size), type: isJs ? 'js' : 'css', isEntry, isRoute };
})
.sort((a, b) => b.sizeKb - a.sizeKb);
const findings = [];
for (const a of assets.filter((x) => x.type === 'js')) {
if (a.isEntry && a.sizeKb > BUDGETS.entryChunkKb) {
findings.push({
severity: 'error',
file: a.file,
sizeKb: a.sizeKb,
budgetKb: BUDGETS.entryChunkKb,
message: 'Entry chunk over budget — apply route-level code splitting (SKILL.md §2), then vendor chunking (§3).',
});
} else if (a.sizeKb > BUDGETS.anyChunkKb) {
findings.push({
severity: 'error',
file: a.file,
sizeKb: a.sizeKb,
budgetKb: BUDGETS.anyChunkKb,
message: 'Chunk over budget — split its largest dependency into its own manualChunks entry (§3). Check for substring matches absorbing extra packages.',
});
} else if (a.isRoute && a.sizeKb > BUDGETS.routeChunkKb) {
findings.push({
severity: 'warning',
file: a.file,
sizeKb: a.sizeKb,
budgetKb: BUDGETS.routeChunkKb,
message: 'Route chunk is heavy — lazy-load this page\'s heavy dependencies or move shared code up.',
});
}
}
const cssTotal = kb(assets.filter((a) => a.type === 'css').reduce((s, a) => s + a.sizeKb * 1024, 0));
if (cssTotal > BUDGETS.cssTotalKb) {
findings.push({
severity: 'warning',
file: '(all css)',
sizeKb: cssTotal,
budgetKb: BUDGETS.cssTotalKb,
message: 'CSS total over budget — check Tailwind content globs for over-broad patterns.',
});
}
const hasVendorSplit = assets.some((a) => /^(react|router|vendor|charts|radix|icons)-/.test(a.file));
const errors = findings.filter((f) => f.severity === 'error');
const result = {
distDir,
budgets: BUDGETS,
vendorChunksDetected: hasVendorSplit,
assets,
findings,
pass: errors.length === 0,
};
if (asJson) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`Assets in ${assetsDir} (largest first):\n`);
for (const a of assets) {
const flag = findings.find((f) => f.file === a.file);
const mark = flag ? (flag.severity === 'error' ? '✗' : '⚠') : '✓';
console.log(` ${mark} ${a.sizeKb.toString().padStart(9)} kB ${a.file}`);
}
console.log(`\nVendor chunks detected: ${hasVendorSplit ? 'yes' : 'no (see SKILL.md §3)'}`);
if (findings.length) {
console.log(`\n${findings.length} finding(s):`);
for (const f of findings) console.log(` [${f.severity}] ${f.file} (${f.sizeKb} kB > ${f.budgetKb} kB): ${f.message}`);
} else {
console.log('\nAll budgets pass.');
}
}
process.exit(result.pass ? 0 : 1);