@fragments-sdk/cli 0.7.17 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +227 -53
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-QLTLLQBI.js → chunk-2JIKCJX3.js} +312 -24
- package/dist/chunk-2JIKCJX3.js.map +1 -0
- package/dist/{chunk-57OW43NL.js → chunk-CJEGT3WD.js} +2 -2
- package/dist/{chunk-7CRC46HV.js → chunk-GOVI6COW.js} +13 -3
- package/dist/chunk-GOVI6COW.js.map +1 -0
- package/dist/{chunk-WLXFE6XW.js → chunk-NGIMCIK2.js} +60 -2
- package/dist/chunk-NGIMCIK2.js.map +1 -0
- package/dist/{chunk-M42XIHPV.js → chunk-WI6SLMSO.js} +2 -2
- package/dist/core/index.d.ts +110 -3
- package/dist/core/index.js +12 -2
- package/dist/{defineFragment-BI9KoPrs.d.ts → defineFragment-D0UTve-I.d.ts} +9 -0
- package/dist/{generate-ICIPKCKV.js → generate-35OIMW4Y.js} +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/{init-DIZ6UNBL.js → init-KFYN37ZY.js} +4 -4
- package/dist/mcp-bin.js +67 -3
- package/dist/mcp-bin.js.map +1 -1
- package/dist/{scan-X3DI2X5G.js → scan-65RH3QMM.js} +5 -5
- package/dist/{service-JEWWTSKI.js → service-A5GIGGGK.js} +3 -3
- package/dist/{static-viewer-JIWCYKVK.js → static-viewer-NSODM5VX.js} +3 -3
- package/dist/{test-36UELXTE.js → test-RPWZAYSJ.js} +3 -3
- package/dist/{tokens-K2AGUUOJ.js → tokens-NIXSZRX7.js} +4 -4
- package/dist/{viewer-QKIAPTPG.js → viewer-HZK4BSDK.js} +43 -12
- package/dist/viewer-HZK4BSDK.js.map +1 -0
- package/package.json +3 -3
- package/src/bin.ts +32 -0
- package/src/build.ts +47 -0
- package/src/commands/perf.ts +249 -0
- package/src/core/bundle-measurer.ts +421 -0
- package/src/core/index.ts +16 -0
- package/src/core/performance-presets.ts +142 -0
- package/src/core/schema.ts +10 -0
- package/src/core/types.ts +6 -0
- package/src/mcp/server.ts +77 -0
- package/src/viewer/components/BottomPanel.tsx +8 -0
- package/src/viewer/components/PerformancePanel.tsx +301 -0
- package/src/viewer/hooks/useAppState.ts +1 -1
- package/src/viewer/vite-plugin.ts +36 -0
- package/dist/chunk-7CRC46HV.js.map +0 -1
- package/dist/chunk-QLTLLQBI.js.map +0 -1
- package/dist/chunk-WLXFE6XW.js.map +0 -1
- package/dist/viewer-QKIAPTPG.js.map +0 -1
- /package/dist/{chunk-57OW43NL.js.map → chunk-CJEGT3WD.js.map} +0 -0
- /package/dist/{chunk-M42XIHPV.js.map → chunk-WI6SLMSO.js.map} +0 -0
- /package/dist/{generate-ICIPKCKV.js.map → generate-35OIMW4Y.js.map} +0 -0
- /package/dist/{init-DIZ6UNBL.js.map → init-KFYN37ZY.js.map} +0 -0
- /package/dist/{scan-X3DI2X5G.js.map → scan-65RH3QMM.js.map} +0 -0
- /package/dist/{service-JEWWTSKI.js.map → service-A5GIGGGK.js.map} +0 -0
- /package/dist/{static-viewer-JIWCYKVK.js.map → static-viewer-NSODM5VX.js.map} +0 -0
- /package/dist/{test-36UELXTE.js.map → test-RPWZAYSJ.js.map} +0 -0
- /package/dist/{tokens-K2AGUUOJ.js.map → tokens-NIXSZRX7.js.map} +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fragments perf` — Component performance profiling command.
|
|
3
|
+
*
|
|
4
|
+
* Measures bundle sizes for all components, classifies complexity,
|
|
5
|
+
* compares against budgets, and optionally writes results to fragments.json.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { loadConfig } from '../core/node.js';
|
|
12
|
+
import { BRAND } from '../core/index.js';
|
|
13
|
+
import {
|
|
14
|
+
resolvePerformanceConfig,
|
|
15
|
+
formatBytes,
|
|
16
|
+
budgetBar,
|
|
17
|
+
type PerformanceConfig,
|
|
18
|
+
type ComplexityTier,
|
|
19
|
+
} from '../core/performance-presets.js';
|
|
20
|
+
import {
|
|
21
|
+
measureBundleSizes,
|
|
22
|
+
toPerformanceData,
|
|
23
|
+
} from '../core/bundle-measurer.js';
|
|
24
|
+
import type {
|
|
25
|
+
CompiledFragmentsFile,
|
|
26
|
+
PerformanceData,
|
|
27
|
+
PerformanceSummary,
|
|
28
|
+
} from '@fragments-sdk/context/types';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Types
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface PerfOptions {
|
|
35
|
+
config?: string;
|
|
36
|
+
json?: boolean;
|
|
37
|
+
component?: string;
|
|
38
|
+
write?: boolean;
|
|
39
|
+
concurrency?: number;
|
|
40
|
+
detail?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PerfResult {
|
|
44
|
+
success: boolean;
|
|
45
|
+
total: number;
|
|
46
|
+
overBudget: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Command implementation
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export async function perf(options: PerfOptions): Promise<PerfResult> {
|
|
54
|
+
const { config: configPath, json, component, concurrency, detail } = options;
|
|
55
|
+
const shouldWrite = options.write !== false; // default true
|
|
56
|
+
|
|
57
|
+
// Load config
|
|
58
|
+
const { config, configDir } = await loadConfig(configPath);
|
|
59
|
+
const perfConfig = resolvePerformanceConfig(config.performance ?? 'standard');
|
|
60
|
+
|
|
61
|
+
// Load fragments.json
|
|
62
|
+
const outFile = resolve(configDir, config.outFile ?? BRAND.outFile);
|
|
63
|
+
let data: CompiledFragmentsFile;
|
|
64
|
+
try {
|
|
65
|
+
data = JSON.parse(await readFile(outFile, 'utf-8'));
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Could not read ${BRAND.outFile}. Run \`${BRAND.cliCommand} build\` first.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Filter to specific component if requested
|
|
73
|
+
let fragments = data.fragments;
|
|
74
|
+
if (component) {
|
|
75
|
+
const match = Object.entries(fragments).find(
|
|
76
|
+
([name]) => name.toLowerCase() === component.toLowerCase()
|
|
77
|
+
);
|
|
78
|
+
if (!match) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Component "${component}" not found in ${BRAND.outFile}.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
fragments = { [match[0]]: match[1] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!json) {
|
|
87
|
+
console.log(pc.cyan(`\n${BRAND.name} Performance Profiler\n`));
|
|
88
|
+
console.log(pc.dim(`Preset: ${perfConfig.preset} (${formatBytes(perfConfig.budgets.bundleSize)} budget)`));
|
|
89
|
+
console.log(pc.dim(`Measuring ${Object.keys(fragments).length} component(s)...\n`));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Measure
|
|
93
|
+
const result = await measureBundleSizes(fragments, configDir, {
|
|
94
|
+
concurrency: concurrency ?? 4,
|
|
95
|
+
perfConfig,
|
|
96
|
+
onProgress: !json ? (done, total, name) => {
|
|
97
|
+
process.stdout.write(`\r${pc.dim(`[${done}/${total}]`)} ${name}`);
|
|
98
|
+
} : undefined,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!json) {
|
|
102
|
+
// Clear progress line
|
|
103
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build performance data for each component
|
|
107
|
+
const perfResults: Array<{
|
|
108
|
+
name: string;
|
|
109
|
+
data: PerformanceData;
|
|
110
|
+
}> = [];
|
|
111
|
+
|
|
112
|
+
for (const [name, measurement] of result.measurements) {
|
|
113
|
+
const fragment = fragments[name];
|
|
114
|
+
const contractBudget = fragment?.contract?.performanceBudget as number | undefined;
|
|
115
|
+
const perfData = toPerformanceData(measurement, perfConfig, contractBudget);
|
|
116
|
+
perfResults.push({ name, data: perfData });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Sort by gzip size descending
|
|
120
|
+
perfResults.sort((a, b) => b.data.bundleSize - a.data.bundleSize);
|
|
121
|
+
|
|
122
|
+
// Summary stats
|
|
123
|
+
const tiers: Record<ComplexityTier, number> = { lightweight: 0, moderate: 0, heavy: 0 };
|
|
124
|
+
let overBudgetCount = 0;
|
|
125
|
+
for (const { data: d } of perfResults) {
|
|
126
|
+
tiers[d.complexity]++;
|
|
127
|
+
if (d.overBudget) overBudgetCount++;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (json) {
|
|
131
|
+
// JSON output mode
|
|
132
|
+
const output = {
|
|
133
|
+
preset: perfConfig.preset,
|
|
134
|
+
budget: perfConfig.budgets.bundleSize,
|
|
135
|
+
total: perfResults.length,
|
|
136
|
+
overBudget: overBudgetCount,
|
|
137
|
+
tiers,
|
|
138
|
+
components: perfResults.map(({ name, data: d }) => ({
|
|
139
|
+
name,
|
|
140
|
+
...d,
|
|
141
|
+
})),
|
|
142
|
+
errors: result.errors,
|
|
143
|
+
elapsed: result.elapsed,
|
|
144
|
+
};
|
|
145
|
+
console.log(JSON.stringify(output, null, 2));
|
|
146
|
+
} else {
|
|
147
|
+
// Table output
|
|
148
|
+
const nameWidth = Math.max(20, ...perfResults.map(r => r.name.length)) + 2;
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
pc.bold(
|
|
152
|
+
'Component'.padEnd(nameWidth) +
|
|
153
|
+
'Gzip'.padStart(8) +
|
|
154
|
+
'Raw'.padStart(10) +
|
|
155
|
+
'Budget'.padStart(8) +
|
|
156
|
+
'Tier'.padStart(14) +
|
|
157
|
+
' Bar'
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
console.log(pc.dim('─'.repeat(nameWidth + 60)));
|
|
161
|
+
|
|
162
|
+
for (const { name, data: d } of perfResults) {
|
|
163
|
+
const tierColor = d.complexity === 'lightweight' ? pc.green
|
|
164
|
+
: d.complexity === 'moderate' ? pc.yellow
|
|
165
|
+
: pc.red;
|
|
166
|
+
const budgetColor = d.overBudget ? pc.red : pc.green;
|
|
167
|
+
|
|
168
|
+
console.log(
|
|
169
|
+
name.padEnd(nameWidth) +
|
|
170
|
+
formatBytes(d.bundleSize).padStart(8) +
|
|
171
|
+
formatBytes(d.rawSize).padStart(10) +
|
|
172
|
+
budgetColor(`${d.budgetPercent}%`.padStart(8)) +
|
|
173
|
+
tierColor(d.complexity.padStart(14)) +
|
|
174
|
+
' ' + budgetBar(d.budgetPercent)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Show import breakdown for over-budget components or when --detail is set
|
|
178
|
+
if (d.imports?.length && (detail || d.overBudget)) {
|
|
179
|
+
for (const imp of d.imports.slice(0, 5)) {
|
|
180
|
+
const barWidth = Math.max(1, Math.round(imp.percent / 5));
|
|
181
|
+
const impBar = pc.dim('█'.repeat(barWidth));
|
|
182
|
+
console.log(
|
|
183
|
+
pc.dim(' └── ') +
|
|
184
|
+
pc.dim(imp.path.length > 50 ? '…' + imp.path.slice(-49) : imp.path) +
|
|
185
|
+
pc.dim(` → ${formatBytes(imp.bytes)} (${imp.percent}%) `) +
|
|
186
|
+
impBar
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (d.imports.length > 5) {
|
|
190
|
+
console.log(pc.dim(` └── … and ${d.imports.length - 5} more files`));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(pc.dim('─'.repeat(nameWidth + 60)));
|
|
196
|
+
|
|
197
|
+
// Error summary
|
|
198
|
+
if (result.errors.length > 0) {
|
|
199
|
+
console.log(pc.yellow(`\n${result.errors.length} component(s) could not be measured:`));
|
|
200
|
+
for (const err of result.errors.slice(0, 5)) {
|
|
201
|
+
console.log(pc.dim(` ${err.name}: ${err.error}`));
|
|
202
|
+
}
|
|
203
|
+
if (result.errors.length > 5) {
|
|
204
|
+
console.log(pc.dim(` ... and ${result.errors.length - 5} more`));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Summary
|
|
209
|
+
console.log(`\n${pc.bold('Summary')}`);
|
|
210
|
+
console.log(` Total: ${perfResults.length} components measured in ${(result.elapsed / 1000).toFixed(1)}s`);
|
|
211
|
+
console.log(` Tiers: ${pc.green(`${tiers.lightweight} lightweight`)} · ${pc.yellow(`${tiers.moderate} moderate`)} · ${pc.red(`${tiers.heavy} heavy`)}`);
|
|
212
|
+
|
|
213
|
+
if (overBudgetCount > 0) {
|
|
214
|
+
console.log(pc.red(` Over budget: ${overBudgetCount} component(s) exceed ${formatBytes(perfConfig.budgets.bundleSize)} budget`));
|
|
215
|
+
} else {
|
|
216
|
+
console.log(pc.green(' All components within budget'));
|
|
217
|
+
}
|
|
218
|
+
console.log('');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Write results back to fragments.json
|
|
222
|
+
if (shouldWrite && perfResults.length > 0) {
|
|
223
|
+
for (const { name, data: d } of perfResults) {
|
|
224
|
+
if (data.fragments[name]) {
|
|
225
|
+
data.fragments[name].performance = d;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
data.performanceSummary = {
|
|
230
|
+
preset: perfConfig.preset,
|
|
231
|
+
budget: perfConfig.budgets.bundleSize,
|
|
232
|
+
total: perfResults.length,
|
|
233
|
+
overBudget: overBudgetCount,
|
|
234
|
+
tiers,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
await writeFile(outFile, JSON.stringify(data));
|
|
238
|
+
|
|
239
|
+
if (!json) {
|
|
240
|
+
console.log(pc.dim(`Results written to ${BRAND.outFile}`));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
success: overBudgetCount === 0,
|
|
246
|
+
total: perfResults.length,
|
|
247
|
+
overBudget: overBudgetCount,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle size measurement for individual components.
|
|
3
|
+
*
|
|
4
|
+
* Uses esbuild to create single-entry bundles per component,
|
|
5
|
+
* then measures minified + gzipped size. CSS/SCSS/SVG are stubbed
|
|
6
|
+
* out since we only measure the JS bundle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { build, type Metafile } from 'esbuild';
|
|
10
|
+
import { gzipSync } from 'node:zlib';
|
|
11
|
+
import { resolve, dirname, join, basename } from 'node:path';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import type { CompiledFragment } from '@fragments-sdk/context/types';
|
|
14
|
+
import {
|
|
15
|
+
resolvePerformanceConfig,
|
|
16
|
+
classifyComplexity,
|
|
17
|
+
type PerformanceConfig,
|
|
18
|
+
type PerformanceData,
|
|
19
|
+
} from './performance-presets.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface BundleImport {
|
|
26
|
+
path: string;
|
|
27
|
+
bytes: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BundleMeasurement {
|
|
31
|
+
name: string;
|
|
32
|
+
rawBytes: number;
|
|
33
|
+
gzipBytes: number;
|
|
34
|
+
/** Per-file byte breakdown from esbuild metafile */
|
|
35
|
+
imports?: BundleImport[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MeasureOptions {
|
|
39
|
+
/** Max concurrent esbuild invocations (default: 4) */
|
|
40
|
+
concurrency?: number;
|
|
41
|
+
/** Performance config for budget calculation */
|
|
42
|
+
perfConfig?: PerformanceConfig;
|
|
43
|
+
/** Callback for progress reporting */
|
|
44
|
+
onProgress?: (completed: number, total: number, name: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MeasureResult {
|
|
48
|
+
measurements: Map<string, BundleMeasurement>;
|
|
49
|
+
errors: Array<{ name: string; error: string }>;
|
|
50
|
+
elapsed: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Entry point resolution
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the component entry point from a fragment file path.
|
|
59
|
+
* Fragments are colocated: `Button/Button.fragment.tsx` → `Button/index.tsx`
|
|
60
|
+
*/
|
|
61
|
+
export function resolveEntryPoint(fragmentFilePath: string, configDir: string): string | null {
|
|
62
|
+
const absPath = resolve(configDir, fragmentFilePath);
|
|
63
|
+
const dir = dirname(absPath);
|
|
64
|
+
|
|
65
|
+
// Try sibling index files
|
|
66
|
+
const candidates = ['index.tsx', 'index.ts', 'index.jsx', 'index.js'];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const path = join(dir, candidate);
|
|
69
|
+
if (existsSync(path)) return path;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Import graph grouping
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalize an esbuild metafile path to a human-friendly label.
|
|
81
|
+
* Local files → "Markdown" (component name)
|
|
82
|
+
* node_modules → "react-markdown" (package name only)
|
|
83
|
+
*/
|
|
84
|
+
function labelForPath(filePath: string): string {
|
|
85
|
+
// node_modules: extract package name from the LAST node_modules/ segment
|
|
86
|
+
// (pnpm store paths have multiple: .pnpm/pkg@ver/node_modules/pkg/...)
|
|
87
|
+
const lastNmIdx = filePath.lastIndexOf('node_modules/');
|
|
88
|
+
if (lastNmIdx >= 0) {
|
|
89
|
+
const afterNm = filePath.slice(lastNmIdx + 'node_modules/'.length);
|
|
90
|
+
// Scoped package: @scope/name, otherwise just name
|
|
91
|
+
if (afterNm.startsWith('@')) {
|
|
92
|
+
const parts = afterNm.split('/');
|
|
93
|
+
return parts.slice(0, 2).join('/');
|
|
94
|
+
}
|
|
95
|
+
return afterNm.split('/')[0];
|
|
96
|
+
}
|
|
97
|
+
// Local component: extract component name from path
|
|
98
|
+
const componentsIdx = filePath.indexOf('components/');
|
|
99
|
+
if (componentsIdx >= 0) {
|
|
100
|
+
const afterComponents = filePath.slice(componentsIdx + 'components/'.length);
|
|
101
|
+
const componentName = afterComponents.split('/')[0];
|
|
102
|
+
return componentName;
|
|
103
|
+
}
|
|
104
|
+
const srcIdx = filePath.indexOf('src/');
|
|
105
|
+
if (srcIdx >= 0) return filePath.slice(srcIdx);
|
|
106
|
+
return filePath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Trace the import graph from the entry point's direct imports.
|
|
111
|
+
* Groups all transitive dependencies under each direct import,
|
|
112
|
+
* so "Markdown → 49KB" instead of 10 individual micromark packages.
|
|
113
|
+
*/
|
|
114
|
+
function groupImportsByDirectDep(
|
|
115
|
+
metafile: Metafile,
|
|
116
|
+
entryPoint: string,
|
|
117
|
+
): BundleImport[] {
|
|
118
|
+
const inputs = metafile.inputs;
|
|
119
|
+
const outputKey = Object.keys(metafile.outputs)[0];
|
|
120
|
+
const outputMeta = outputKey ? metafile.outputs[outputKey] : undefined;
|
|
121
|
+
if (!outputMeta?.inputs) return [];
|
|
122
|
+
|
|
123
|
+
// Map of file → bytesInOutput
|
|
124
|
+
const bytesMap = new Map<string, number>();
|
|
125
|
+
for (const [path, info] of Object.entries(outputMeta.inputs)) {
|
|
126
|
+
if (info.bytesInOutput > 0) {
|
|
127
|
+
bytesMap.set(path, info.bytesInOutput);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Find the entry point key in metafile inputs (esbuild may use relative paths)
|
|
132
|
+
let entryKey: string | undefined;
|
|
133
|
+
for (const key of Object.keys(inputs)) {
|
|
134
|
+
if (key === entryPoint || entryPoint.endsWith(key) || key.endsWith(basename(entryPoint))) {
|
|
135
|
+
// Match by suffix — esbuild paths are relative to cwd
|
|
136
|
+
const entryDir = dirname(entryPoint);
|
|
137
|
+
if (key.includes(basename(entryDir))) {
|
|
138
|
+
entryKey = key;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Fallback: try exact match or find by component name
|
|
144
|
+
if (!entryKey) {
|
|
145
|
+
const entryBasename = basename(dirname(entryPoint)); // e.g., "Message"
|
|
146
|
+
for (const key of Object.keys(inputs)) {
|
|
147
|
+
if (key.includes(`/${entryBasename}/index.`)) {
|
|
148
|
+
entryKey = key;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!entryKey || !inputs[entryKey]) {
|
|
155
|
+
// Fallback: return flat list grouped by npm package
|
|
156
|
+
return groupByPackage(bytesMap);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Get direct imports of the entry point
|
|
160
|
+
const directImports = inputs[entryKey].imports
|
|
161
|
+
.map((imp) => imp.path)
|
|
162
|
+
.filter((p) => inputs[p]); // Only resolved inputs
|
|
163
|
+
|
|
164
|
+
// BFS from each direct import to find its transitive closure
|
|
165
|
+
const claimed = new Set<string>();
|
|
166
|
+
claimed.add(entryKey); // Don't count entry in any group
|
|
167
|
+
|
|
168
|
+
const groupMap = new Map<string, BundleImport>();
|
|
169
|
+
|
|
170
|
+
for (const directPath of directImports) {
|
|
171
|
+
if (claimed.has(directPath)) continue;
|
|
172
|
+
|
|
173
|
+
// BFS to find all files reachable from this direct import
|
|
174
|
+
const queue = [directPath];
|
|
175
|
+
const reachable = new Set<string>();
|
|
176
|
+
while (queue.length > 0) {
|
|
177
|
+
const current = queue.pop()!;
|
|
178
|
+
if (reachable.has(current) || claimed.has(current)) continue;
|
|
179
|
+
reachable.add(current);
|
|
180
|
+
claimed.add(current);
|
|
181
|
+
|
|
182
|
+
const entry = inputs[current];
|
|
183
|
+
if (entry?.imports) {
|
|
184
|
+
for (const imp of entry.imports) {
|
|
185
|
+
if (inputs[imp.path] && !claimed.has(imp.path)) {
|
|
186
|
+
queue.push(imp.path);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Sum bytes for all reachable files
|
|
193
|
+
let totalBytes = 0;
|
|
194
|
+
for (const path of reachable) {
|
|
195
|
+
totalBytes += bytesMap.get(path) ?? 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (totalBytes > 0) {
|
|
199
|
+
const label = labelForPath(directPath);
|
|
200
|
+
// Merge with existing group if same label (e.g., two files from same package)
|
|
201
|
+
const existing = groupMap.get(label);
|
|
202
|
+
if (existing) {
|
|
203
|
+
existing.bytes += totalBytes;
|
|
204
|
+
} else {
|
|
205
|
+
const entry = { path: label, bytes: totalBytes };
|
|
206
|
+
groupMap.set(label, entry);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Add entry point's own bytes + merge any same-component imports into (self)
|
|
212
|
+
const entryLabel = labelForPath(entryKey);
|
|
213
|
+
const selfLabel = entryLabel + ' (self)';
|
|
214
|
+
let selfBytes = bytesMap.get(entryKey) ?? 0;
|
|
215
|
+
|
|
216
|
+
// Merge sibling files (e.g., .module.scss) that resolved to same component name
|
|
217
|
+
const siblingGroup = groupMap.get(entryLabel);
|
|
218
|
+
if (siblingGroup) {
|
|
219
|
+
selfBytes += siblingGroup.bytes;
|
|
220
|
+
groupMap.delete(entryLabel);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (selfBytes > 0) {
|
|
224
|
+
groupMap.set(selfLabel, { path: selfLabel, bytes: selfBytes });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Add any unclaimed files (shouldn't happen often)
|
|
228
|
+
let unclaimedBytes = 0;
|
|
229
|
+
for (const [path, bytes] of bytesMap) {
|
|
230
|
+
if (!claimed.has(path)) unclaimedBytes += bytes;
|
|
231
|
+
}
|
|
232
|
+
if (unclaimedBytes > 0) {
|
|
233
|
+
groupMap.set('(other)', { path: '(other)', bytes: unclaimedBytes });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [...groupMap.values()].sort((a, b) => b.bytes - a.bytes);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fallback: group flat file list by npm package name.
|
|
241
|
+
*/
|
|
242
|
+
function groupByPackage(bytesMap: Map<string, number>): BundleImport[] {
|
|
243
|
+
const groups = new Map<string, number>();
|
|
244
|
+
for (const [path, bytes] of bytesMap) {
|
|
245
|
+
const label = labelForPath(path);
|
|
246
|
+
// For node_modules, just use package name; for local files, use full label
|
|
247
|
+
const key = label.includes('/') && !label.startsWith('components/')
|
|
248
|
+
? label.split('/').slice(0, label.startsWith('@') ? 2 : 1).join('/')
|
|
249
|
+
: label;
|
|
250
|
+
groups.set(key, (groups.get(key) ?? 0) + bytes);
|
|
251
|
+
}
|
|
252
|
+
return [...groups.entries()]
|
|
253
|
+
.map(([path, bytes]) => ({ path, bytes }))
|
|
254
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Single component measurement
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Measure the bundle size of a single component entry point.
|
|
263
|
+
*/
|
|
264
|
+
export async function measureSingleComponent(
|
|
265
|
+
entryPoint: string,
|
|
266
|
+
name: string
|
|
267
|
+
): Promise<BundleMeasurement> {
|
|
268
|
+
const result = await build({
|
|
269
|
+
entryPoints: [entryPoint],
|
|
270
|
+
bundle: true,
|
|
271
|
+
write: false,
|
|
272
|
+
minify: true,
|
|
273
|
+
metafile: true,
|
|
274
|
+
format: 'esm',
|
|
275
|
+
target: 'es2020',
|
|
276
|
+
platform: 'browser',
|
|
277
|
+
treeShaking: true,
|
|
278
|
+
external: [
|
|
279
|
+
'react',
|
|
280
|
+
'react-dom',
|
|
281
|
+
'react/jsx-runtime',
|
|
282
|
+
'react/jsx-dev-runtime',
|
|
283
|
+
// Optional peer deps — excluded from measurement
|
|
284
|
+
'recharts',
|
|
285
|
+
'shiki',
|
|
286
|
+
'react-day-picker',
|
|
287
|
+
'@tanstack/react-table',
|
|
288
|
+
'date-fns',
|
|
289
|
+
'@base-ui-components/*',
|
|
290
|
+
'@base-ui/react/*',
|
|
291
|
+
],
|
|
292
|
+
loader: {
|
|
293
|
+
'.scss': 'empty',
|
|
294
|
+
'.css': 'empty',
|
|
295
|
+
'.svg': 'empty',
|
|
296
|
+
'.png': 'empty',
|
|
297
|
+
'.jpg': 'empty',
|
|
298
|
+
'.gif': 'empty',
|
|
299
|
+
'.woff': 'empty',
|
|
300
|
+
'.woff2': 'empty',
|
|
301
|
+
'.ttf': 'empty',
|
|
302
|
+
'.eot': 'empty',
|
|
303
|
+
},
|
|
304
|
+
logLevel: 'silent',
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const output = result.outputFiles[0];
|
|
308
|
+
const rawBytes = output.contents.byteLength;
|
|
309
|
+
const gzipBytes = gzipSync(output.contents).byteLength;
|
|
310
|
+
|
|
311
|
+
// Group imports by the entry point's direct dependencies
|
|
312
|
+
const imports = result.metafile
|
|
313
|
+
? groupImportsByDirectDep(result.metafile, entryPoint)
|
|
314
|
+
: undefined;
|
|
315
|
+
|
|
316
|
+
return { name, rawBytes, gzipBytes, imports };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Batch measurement with concurrency control
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Measure bundle sizes for all fragments with concurrency control.
|
|
325
|
+
*/
|
|
326
|
+
export async function measureBundleSizes(
|
|
327
|
+
fragments: Record<string, CompiledFragment>,
|
|
328
|
+
configDir: string,
|
|
329
|
+
options: MeasureOptions = {}
|
|
330
|
+
): Promise<MeasureResult> {
|
|
331
|
+
const concurrency = options.concurrency ?? 4;
|
|
332
|
+
const measurements = new Map<string, BundleMeasurement>();
|
|
333
|
+
const errors: MeasureResult['errors'] = [];
|
|
334
|
+
const start = Date.now();
|
|
335
|
+
|
|
336
|
+
// Resolve all entry points first
|
|
337
|
+
const entries: Array<{ name: string; entryPoint: string }> = [];
|
|
338
|
+
for (const [name, fragment] of Object.entries(fragments)) {
|
|
339
|
+
const entryPoint = resolveEntryPoint(fragment.filePath, configDir);
|
|
340
|
+
if (entryPoint) {
|
|
341
|
+
entries.push({ name, entryPoint });
|
|
342
|
+
} else {
|
|
343
|
+
errors.push({
|
|
344
|
+
name,
|
|
345
|
+
error: `Could not resolve entry point from ${fragment.filePath}`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Process in batches
|
|
351
|
+
let completed = 0;
|
|
352
|
+
for (let i = 0; i < entries.length; i += concurrency) {
|
|
353
|
+
const batch = entries.slice(i, i + concurrency);
|
|
354
|
+
const results = await Promise.allSettled(
|
|
355
|
+
batch.map(({ name, entryPoint }) =>
|
|
356
|
+
measureSingleComponent(entryPoint, name)
|
|
357
|
+
)
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
for (let j = 0; j < results.length; j++) {
|
|
361
|
+
const result = results[j];
|
|
362
|
+
const { name } = batch[j];
|
|
363
|
+
completed++;
|
|
364
|
+
|
|
365
|
+
if (result.status === 'fulfilled') {
|
|
366
|
+
measurements.set(name, result.value);
|
|
367
|
+
} else {
|
|
368
|
+
errors.push({
|
|
369
|
+
name,
|
|
370
|
+
error: result.reason instanceof Error
|
|
371
|
+
? result.reason.message
|
|
372
|
+
: String(result.reason),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
options.onProgress?.(completed, entries.length, name);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
measurements,
|
|
382
|
+
errors,
|
|
383
|
+
elapsed: Date.now() - start,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Convert to PerformanceData
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Convert a raw measurement into a PerformanceData object.
|
|
393
|
+
* Uses per-component budget override if set in the fragment's contract.
|
|
394
|
+
*/
|
|
395
|
+
export function toPerformanceData(
|
|
396
|
+
measurement: BundleMeasurement,
|
|
397
|
+
config: PerformanceConfig,
|
|
398
|
+
contractBudget?: number
|
|
399
|
+
): PerformanceData {
|
|
400
|
+
const budget = contractBudget ?? config.budgets.bundleSize;
|
|
401
|
+
const budgetPercent = Math.round((measurement.gzipBytes / budget) * 100);
|
|
402
|
+
|
|
403
|
+
// Build top imports list (top 10, with percentage)
|
|
404
|
+
const imports = measurement.imports
|
|
405
|
+
?.slice(0, 10)
|
|
406
|
+
.map((imp) => ({
|
|
407
|
+
path: imp.path,
|
|
408
|
+
bytes: imp.bytes,
|
|
409
|
+
percent: Math.round((imp.bytes / measurement.rawBytes) * 100),
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
bundleSize: measurement.gzipBytes,
|
|
414
|
+
rawSize: measurement.rawBytes,
|
|
415
|
+
complexity: classifyComplexity(measurement.gzipBytes),
|
|
416
|
+
budgetPercent,
|
|
417
|
+
overBudget: budgetPercent > 100,
|
|
418
|
+
measuredAt: new Date().toISOString(),
|
|
419
|
+
...(imports && imports.length > 0 ? { imports } : {}),
|
|
420
|
+
};
|
|
421
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -64,6 +64,22 @@ export type {
|
|
|
64
64
|
CompiledTokenData,
|
|
65
65
|
} from "./types.js";
|
|
66
66
|
|
|
67
|
+
// Performance presets
|
|
68
|
+
export {
|
|
69
|
+
resolvePerformanceConfig,
|
|
70
|
+
classifyComplexity,
|
|
71
|
+
formatBytes,
|
|
72
|
+
budgetBar,
|
|
73
|
+
PRESET_NAMES,
|
|
74
|
+
} from "./performance-presets.js";
|
|
75
|
+
export type {
|
|
76
|
+
PerformanceBudgets,
|
|
77
|
+
PerformanceConfig,
|
|
78
|
+
ComplexityTier,
|
|
79
|
+
PerformanceData as PerfData,
|
|
80
|
+
PerformanceSummary as PerfSummary,
|
|
81
|
+
} from "./performance-presets.js";
|
|
82
|
+
|
|
67
83
|
// Token types
|
|
68
84
|
export type {
|
|
69
85
|
TokenCategory,
|