@fragments-sdk/cli 0.7.16 → 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.
Files changed (54) hide show
  1. package/dist/bin.js +227 -53
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-QLTLLQBI.js → chunk-2JIKCJX3.js} +312 -24
  4. package/dist/chunk-2JIKCJX3.js.map +1 -0
  5. package/dist/{chunk-57OW43NL.js → chunk-CJEGT3WD.js} +2 -2
  6. package/dist/{chunk-7CRC46HV.js → chunk-GOVI6COW.js} +13 -3
  7. package/dist/chunk-GOVI6COW.js.map +1 -0
  8. package/dist/{chunk-WLXFE6XW.js → chunk-NGIMCIK2.js} +60 -2
  9. package/dist/chunk-NGIMCIK2.js.map +1 -0
  10. package/dist/{chunk-M42XIHPV.js → chunk-WI6SLMSO.js} +2 -2
  11. package/dist/core/index.d.ts +110 -3
  12. package/dist/core/index.js +12 -2
  13. package/dist/{defineFragment-BI9KoPrs.d.ts → defineFragment-D0UTve-I.d.ts} +9 -0
  14. package/dist/{generate-ICIPKCKV.js → generate-35OIMW4Y.js} +4 -4
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +4 -4
  17. package/dist/{init-DIZ6UNBL.js → init-KFYN37ZY.js} +4 -4
  18. package/dist/mcp-bin.js +67 -3
  19. package/dist/mcp-bin.js.map +1 -1
  20. package/dist/{scan-X3DI2X5G.js → scan-65RH3QMM.js} +5 -5
  21. package/dist/{service-JEWWTSKI.js → service-A5GIGGGK.js} +3 -3
  22. package/dist/{static-viewer-JIWCYKVK.js → static-viewer-NSODM5VX.js} +3 -3
  23. package/dist/{test-36UELXTE.js → test-RPWZAYSJ.js} +3 -3
  24. package/dist/{tokens-K2AGUUOJ.js → tokens-NIXSZRX7.js} +4 -4
  25. package/dist/{viewer-QKIAPTPG.js → viewer-HZK4BSDK.js} +43 -12
  26. package/dist/viewer-HZK4BSDK.js.map +1 -0
  27. package/package.json +3 -3
  28. package/src/bin.ts +32 -0
  29. package/src/build.ts +47 -0
  30. package/src/commands/perf.ts +249 -0
  31. package/src/core/bundle-measurer.ts +421 -0
  32. package/src/core/index.ts +16 -0
  33. package/src/core/performance-presets.ts +142 -0
  34. package/src/core/schema.ts +10 -0
  35. package/src/core/types.ts +6 -0
  36. package/src/mcp/server.ts +77 -0
  37. package/src/theme/__tests__/component-contrast.test.ts +210 -157
  38. package/src/viewer/components/BottomPanel.tsx +8 -0
  39. package/src/viewer/components/PerformancePanel.tsx +301 -0
  40. package/src/viewer/hooks/useAppState.ts +1 -1
  41. package/src/viewer/vite-plugin.ts +36 -0
  42. package/dist/chunk-7CRC46HV.js.map +0 -1
  43. package/dist/chunk-QLTLLQBI.js.map +0 -1
  44. package/dist/chunk-WLXFE6XW.js.map +0 -1
  45. package/dist/viewer-QKIAPTPG.js.map +0 -1
  46. /package/dist/{chunk-57OW43NL.js.map → chunk-CJEGT3WD.js.map} +0 -0
  47. /package/dist/{chunk-M42XIHPV.js.map → chunk-WI6SLMSO.js.map} +0 -0
  48. /package/dist/{generate-ICIPKCKV.js.map → generate-35OIMW4Y.js.map} +0 -0
  49. /package/dist/{init-DIZ6UNBL.js.map → init-KFYN37ZY.js.map} +0 -0
  50. /package/dist/{scan-X3DI2X5G.js.map → scan-65RH3QMM.js.map} +0 -0
  51. /package/dist/{service-JEWWTSKI.js.map → service-A5GIGGGK.js.map} +0 -0
  52. /package/dist/{static-viewer-JIWCYKVK.js.map → static-viewer-NSODM5VX.js.map} +0 -0
  53. /package/dist/{test-36UELXTE.js.map → test-RPWZAYSJ.js.map} +0 -0
  54. /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,