@mandujs/core 0.41.2 → 0.43.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 (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,958 +1,958 @@
1
- /**
2
- * Phase 18.η — Bundle analyzer.
3
- *
4
- * Post-processes `.mandu/client/` output + the emitted `BundleManifest` into
5
- * a structured JSON report and a self-contained HTML treemap. Intended for
6
- * `mandu build --analyze` and for programmatic use from deploy scripts / CI.
7
- *
8
- * ── What this module produces ────────────────────────────────────────────────
9
- *
10
- * analyzeBundle(rootDir, manifest) → AnalyzeReport
11
- * {
12
- * islands: [
13
- * {
14
- * name: "home" // island route id
15
- * js: "/.mandu/client/home.island.js"
16
- * totalRaw: 123456
17
- * totalGz: 42017
18
- * priority: "visible"
19
- * shared: ["runtime","vendor"] // chunks referenced
20
- * modules: [ { path, size, gz } ... up to top-20 ]
21
- * }
22
- * ],
23
- * shared: [
24
- * {
25
- * id: "vendor"
26
- * js: "/.mandu/client/_vendor.js"
27
- * size: 258912
28
- * gz: 87120
29
- * usedBy: ["home","dashboard"]
30
- * }
31
- * ],
32
- * summary: {
33
- * totalRaw, totalGz,
34
- * largestIsland: { name, totalRaw },
35
- * heaviestDep: { path, size } // heaviest module seen anywhere
36
- * islandCount, sharedCount,
37
- * }
38
- * }
39
- *
40
- * ── Module-level breakdown ──────────────────────────────────────────────────
41
- *
42
- * When `--sourcemap` was passed to `mandu build`, each JS output gets an
43
- * external `.map` file next to it. We parse the `sources[]` array + the
44
- * `sourcesContent[]` (if present) to derive per-source-file byte sizes. That
45
- * gives us the "top-20 heaviest modules per island" drill-down that the HTML
46
- * report renders as a second-level treemap.
47
- *
48
- * When sourcemaps are absent we degrade gracefully:
49
- * - `modules: []` on every island entry
50
- * - `heaviestDep: { path: "<sourcemap unavailable>", size: 0 }`
51
- * - HTML report still renders the island-level treemap
52
- *
53
- * ── Design choices ──────────────────────────────────────────────────────────
54
- *
55
- * - Zero runtime deps. `zlib` (`Bun.gzipSync`) + `fs/promises` only.
56
- * The HTML report inlines its own squarify treemap (~150 LOC) so the
57
- * output file is fully portable — drag-and-drop into any browser, no
58
- * CDN. This is a deliberate no-d3 choice (see `report.html` comment).
59
- * - Pure function. `analyzeBundle()` does filesystem reads but does not
60
- * write. Serialization (`writeReport()` / `renderHtml()`) is separate so
61
- * tests can assert on the report shape without touching disk.
62
- * - Stable output. Islands are sorted by `totalRaw DESC`, shared chunks by
63
- * size DESC, modules within an island by size DESC. CI snapshots won't
64
- * churn on Map iteration order.
65
- *
66
- * ── What this module deliberately does NOT do ───────────────────────────────
67
- *
68
- * - No tree-shaking suggestions. That's an optimizer concern; the report
69
- * is purely descriptive. Agent η's scope ends at "show the developer
70
- * what's in their bundle".
71
- * - No historical / delta comparison. A separate command (future
72
- * `mandu build --analyze-against=prev.json`) can diff two reports.
73
- * - No network. Absolutely nothing is fetched. The report is generated
74
- * from files on disk produced by `buildClientBundles()`.
75
- */
76
-
77
- import fs from "fs/promises";
78
- import path from "path";
79
- import zlib from "zlib";
80
-
81
- import type { BundleManifest } from "./types";
82
- import type { BudgetReport } from "./budget";
83
-
84
- // ============================================================================
85
- // Types
86
- // ============================================================================
87
-
88
- export interface AnalyzeModule {
89
- /** Source path as emitted by the sourcemap (e.g. `node_modules/react/index.js`). */
90
- path: string;
91
- /** Raw byte contribution attributed to this module inside the final bundle. */
92
- size: number;
93
- /** Estimated gzip size (proportional scaling from bundle gz, since gz is non-additive). */
94
- gz: number;
95
- }
96
-
97
- export interface AnalyzeIsland {
98
- /** Island / route id (e.g. `home`, `dashboard`). */
99
- name: string;
100
- /** Absolute `/.mandu/client/<file>.js` path from the manifest. */
101
- js: string;
102
- /** Raw (uncompressed) bytes of the island's JS output. */
103
- totalRaw: number;
104
- /** Real gzip size of the island's JS output — not an estimate. */
105
- totalGz: number;
106
- /** Hydration priority propagated from the manifest. */
107
- priority: "immediate" | "visible" | "idle" | "interaction";
108
- /** Shared-chunk ids this island depends on (e.g. `["runtime", "vendor"]`). */
109
- shared: string[];
110
- /** Top-20 heaviest source modules (empty when sourcemaps are unavailable). */
111
- modules: AnalyzeModule[];
112
- }
113
-
114
- export interface AnalyzeSharedChunk {
115
- /** Logical id — `runtime`, `vendor`, `router`, `fastRefresh.runtime`, etc. */
116
- id: string;
117
- /** `/.mandu/client/...` path from the manifest. */
118
- js: string;
119
- /** Raw byte size of the chunk. */
120
- size: number;
121
- /** Gzip byte size of the chunk. */
122
- gz: number;
123
- /** Every island name that transitively depends on this chunk. */
124
- usedBy: string[];
125
- }
126
-
127
- export interface AnalyzeSummary {
128
- /** Sum of every island + every shared chunk, raw bytes. */
129
- totalRaw: number;
130
- /** Sum of every island + every shared chunk, gzip bytes. */
131
- totalGz: number;
132
- /** The single heaviest island (by raw bytes). */
133
- largestIsland: { name: string; totalRaw: number } | null;
134
- /** The heaviest source module across all islands (source-map derived). */
135
- heaviestDep: { path: string; size: number } | null;
136
- /** Island count — convenience for CLI table rendering. */
137
- islandCount: number;
138
- /** Shared-chunk count. */
139
- sharedCount: number;
140
- /** Deduplication savings: bytes that would have been duplicated if each
141
- * island inlined its shared deps instead of referencing them. */
142
- dedupeSavings: number;
143
- /** Report schema version. Bump on breaking shape changes. */
144
- version: 1;
145
- /** ISO timestamp when the report was generated. */
146
- generatedAt: string;
147
- }
148
-
149
- export interface AnalyzeReport {
150
- islands: AnalyzeIsland[];
151
- shared: AnalyzeSharedChunk[];
152
- summary: AnalyzeSummary;
153
- }
154
-
155
- // ============================================================================
156
- // Size primitives
157
- // ============================================================================
158
-
159
- /**
160
- * Strip the leading `/.mandu/client/` prefix and join against `<rootDir>/.mandu/client/`.
161
- *
162
- * The bundle manifest stores URLs as browser-relative absolute paths
163
- * (`/.mandu/client/<file>`). The analyzer runs on disk, so we need to flip
164
- * the URL into a `fs` path. Returns `null` for any URL that does not match
165
- * the expected shape — defensive against `data:` / `http:` / tampered
166
- * manifests. (The manifest is already Zod-validated at build time, but
167
- * belt-and-braces: one more check means this file is safe to feed an
168
- * unvalidated manifest from an old build.)
169
- */
170
- function urlToFsPath(rootDir: string, url: string | undefined): string | null {
171
- if (!url || typeof url !== "string") return null;
172
- if (!url.startsWith("/.mandu/client/")) return null;
173
- const rel = url.slice("/".length); // keep `.mandu/client/...`
174
- return path.join(rootDir, rel);
175
- }
176
-
177
- /**
178
- * Read a file and return both raw and gzip byte counts. Returns `{ raw: 0,
179
- * gz: 0 }` when the file is missing — the bundler may have emitted a
180
- * stub entry (e.g. `shared.fastRefresh` in prod) and we don't want the
181
- * report to crash on one missing file.
182
- */
183
- async function measureFile(absPath: string): Promise<{ raw: number; gz: number }> {
184
- try {
185
- const buf = await fs.readFile(absPath);
186
- const gz = zlib.gzipSync(buf, { level: 9 });
187
- return { raw: buf.byteLength, gz: gz.byteLength };
188
- } catch {
189
- return { raw: 0, gz: 0 };
190
- }
191
- }
192
-
193
- // ============================================================================
194
- // Sourcemap parsing
195
- // ============================================================================
196
-
197
- interface SourceMapV3 {
198
- version: 3;
199
- sources: string[];
200
- sourcesContent?: (string | null)[];
201
- mappings?: string;
202
- file?: string;
203
- }
204
-
205
- /**
206
- * Parse the sourcemap file associated with a built bundle (same path + `.map`).
207
- *
208
- * Returns a `AnalyzeModule[]` aggregated by source path, largest first,
209
- * truncated at `limit` entries. Size attribution uses `sourcesContent`
210
- * byte length as the best available proxy. When sourcesContent is absent
211
- * (minified-only map) we return an empty array — a stub rather than
212
- * misleading data.
213
- *
214
- * Gzip per module is estimated as `size * (bundleGz / bundleRaw)` — a
215
- * linear proportional attribution. Gzip is not actually additive (two
216
- * identical modules don't double the gz weight), so this is an
217
- * approximation; the HTML treemap labels it as "~gz" to signal that.
218
- */
219
- async function readTopModules(
220
- jsAbsPath: string,
221
- bundleRaw: number,
222
- bundleGz: number,
223
- limit = 20
224
- ): Promise<AnalyzeModule[]> {
225
- const mapPath = `${jsAbsPath}.map`;
226
- let text: string;
227
- try {
228
- text = await fs.readFile(mapPath, "utf8");
229
- } catch {
230
- return [];
231
- }
232
- let map: SourceMapV3;
233
- try {
234
- map = JSON.parse(text) as SourceMapV3;
235
- } catch {
236
- return [];
237
- }
238
- if (!Array.isArray(map.sources) || !Array.isArray(map.sourcesContent)) {
239
- return [];
240
- }
241
-
242
- // One source can appear multiple times in a concatenated chunk; we
243
- // aggregate by path. Use a Map so insertion order is preserved for ties.
244
- const sizeByPath = new Map<string, number>();
245
- for (let i = 0; i < map.sources.length; i++) {
246
- const src = map.sources[i];
247
- const content = map.sourcesContent[i];
248
- if (typeof content !== "string") continue;
249
- // Normalise `../../../node_modules/react/index.js` → `react/index.js`
250
- // so the treemap legend groups every `react/*` under the same prefix.
251
- const norm = normalizeSourcePath(src);
252
- sizeByPath.set(norm, (sizeByPath.get(norm) ?? 0) + content.length);
253
- }
254
-
255
- const gzRatio = bundleRaw > 0 ? bundleGz / bundleRaw : 0;
256
- return [...sizeByPath.entries()]
257
- .sort((a, b) => b[1] - a[1])
258
- .slice(0, limit)
259
- .map(([p, size]) => ({
260
- path: p,
261
- size,
262
- gz: Math.round(size * gzRatio),
263
- }));
264
- }
265
-
266
- /**
267
- * Collapse a Bun.build source path to something recognizable.
268
- *
269
- * Examples:
270
- * `../../../node_modules/react/index.js` → `react/index.js`
271
- * `../../src/app/home/page.tsx` → `src/app/home/page.tsx`
272
- * `bun:react-dom` → `bun:react-dom`
273
- */
274
- export function normalizeSourcePath(raw: string): string {
275
- if (!raw) return "<unknown>";
276
- if (raw.startsWith("bun:")) return raw;
277
- // Normalise Windows slashes FIRST so the relative-prefix strip below
278
- // catches `..\src\foo` as well as `../src/foo`.
279
- let p = raw.replace(/\\/g, "/");
280
- // Strip leading `./` and `../` segments.
281
- p = p.replace(/^(\.\.\/|\.\/)+/, "");
282
- // `node_modules/foo/...` → `foo/...` (keeps dep identity, drops pnpm noise)
283
- const nmIdx = p.lastIndexOf("node_modules/");
284
- if (nmIdx !== -1) {
285
- p = p.slice(nmIdx + "node_modules/".length);
286
- }
287
- return p;
288
- }
289
-
290
- // ============================================================================
291
- // Main analyzer
292
- // ============================================================================
293
-
294
- /**
295
- * Read every artifact referenced by the manifest and build the report.
296
- *
297
- * `rootDir` is the project root (the one containing `.mandu/`).
298
- */
299
- export async function analyzeBundle(
300
- rootDir: string,
301
- manifest: BundleManifest
302
- ): Promise<AnalyzeReport> {
303
- // ── Step 1: Measure shared chunks ────────────────────────────────────────
304
- const shared: AnalyzeSharedChunk[] = [];
305
- const sharedUrls: { id: string; url: string }[] = [];
306
- if (manifest.shared?.runtime) sharedUrls.push({ id: "runtime", url: manifest.shared.runtime });
307
- if (manifest.shared?.vendor) sharedUrls.push({ id: "vendor", url: manifest.shared.vendor });
308
- if (manifest.shared?.router) sharedUrls.push({ id: "router", url: manifest.shared.router });
309
- if (manifest.shared?.fastRefresh?.runtime) {
310
- sharedUrls.push({
311
- id: "fastRefresh.runtime",
312
- url: manifest.shared.fastRefresh.runtime,
313
- });
314
- }
315
- if (manifest.shared?.fastRefresh?.glue) {
316
- sharedUrls.push({ id: "fastRefresh.glue", url: manifest.shared.fastRefresh.glue });
317
- }
318
-
319
- const sharedById = new Map<string, AnalyzeSharedChunk>();
320
- for (const { id, url } of sharedUrls) {
321
- const abs = urlToFsPath(rootDir, url);
322
- if (!abs) continue;
323
- const { raw, gz } = await measureFile(abs);
324
- const entry: AnalyzeSharedChunk = { id, js: url, size: raw, gz, usedBy: [] };
325
- shared.push(entry);
326
- sharedById.set(id, entry);
327
- }
328
-
329
- // ── Step 2: Walk islands / bundles ───────────────────────────────────────
330
- //
331
- // Both `manifest.bundles` (route-level) and `manifest.islands` (per-island
332
- // code-split) describe client JS entrypoints. We treat each as an "island"
333
- // in the report — the shape of the drill-down doesn't differ, only the id.
334
- const islandSources: { name: string; url: string; priority: AnalyzeIsland["priority"]; deps: string[] }[] = [];
335
-
336
- for (const [routeId, entry] of Object.entries(manifest.bundles ?? {})) {
337
- islandSources.push({
338
- name: routeId,
339
- url: entry.js,
340
- priority: entry.priority,
341
- deps: entry.dependencies ?? [],
342
- });
343
- }
344
- for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
345
- // Avoid double-counting: if a per-island chunk shares its route id with
346
- // a route-level bundle, we prefer the island entry (finer granularity).
347
- const existing = islandSources.findIndex((s) => s.name === islandName);
348
- if (existing !== -1) islandSources.splice(existing, 1);
349
- islandSources.push({
350
- name: islandName,
351
- url: entry.js,
352
- priority: entry.priority,
353
- deps: [],
354
- });
355
- }
356
-
357
- const islands: AnalyzeIsland[] = [];
358
- for (const src of islandSources) {
359
- const abs = urlToFsPath(rootDir, src.url);
360
- if (!abs) continue;
361
- const { raw, gz } = await measureFile(abs);
362
- const modules = await readTopModules(abs, raw, gz);
363
-
364
- // Shared-chunk attribution: every island implicitly depends on the
365
- // `runtime` + `vendor` chunks the bundler emits as a contract; `router`
366
- // is added when the app uses SPA navigation. We add the dep names here
367
- // so `shared[].usedBy` stays in sync with the treemap links.
368
- const implicitShared = ["runtime", "vendor"];
369
- if (sharedById.has("router")) implicitShared.push("router");
370
- const sharedDeps = [...new Set([...implicitShared, ...src.deps])].filter((id) =>
371
- sharedById.has(id)
372
- );
373
- for (const depId of sharedDeps) {
374
- const entry = sharedById.get(depId);
375
- if (entry && !entry.usedBy.includes(src.name)) entry.usedBy.push(src.name);
376
- }
377
-
378
- islands.push({
379
- name: src.name,
380
- js: src.url,
381
- totalRaw: raw,
382
- totalGz: gz,
383
- priority: src.priority,
384
- shared: sharedDeps,
385
- modules,
386
- });
387
- }
388
-
389
- islands.sort((a, b) => b.totalRaw - a.totalRaw);
390
- shared.sort((a, b) => b.size - a.size);
391
-
392
- // ── Step 3: Summary ──────────────────────────────────────────────────────
393
- const islandTotalRaw = islands.reduce((s, i) => s + i.totalRaw, 0);
394
- const islandTotalGz = islands.reduce((s, i) => s + i.totalGz, 0);
395
- const sharedTotalRaw = shared.reduce((s, c) => s + c.size, 0);
396
- const sharedTotalGz = shared.reduce((s, c) => s + c.gz, 0);
397
-
398
- // Dedupe savings — if every island had to inline each shared chunk it
399
- // uses, the wire cost would be `sum_over_islands(sharedUsed). Subtracting
400
- // the one-copy cost gives the "load-once" savings the manifest unlocks.
401
- let dedupeSavings = 0;
402
- for (const chunk of shared) {
403
- if (chunk.usedBy.length > 1) {
404
- dedupeSavings += chunk.size * (chunk.usedBy.length - 1);
405
- }
406
- }
407
-
408
- let heaviestDep: AnalyzeSummary["heaviestDep"] = null;
409
- for (const island of islands) {
410
- for (const m of island.modules) {
411
- if (!heaviestDep || m.size > heaviestDep.size) {
412
- heaviestDep = { path: m.path, size: m.size };
413
- }
414
- }
415
- }
416
-
417
- const summary: AnalyzeSummary = {
418
- totalRaw: islandTotalRaw + sharedTotalRaw,
419
- totalGz: islandTotalGz + sharedTotalGz,
420
- largestIsland: islands[0]
421
- ? { name: islands[0].name, totalRaw: islands[0].totalRaw }
422
- : null,
423
- heaviestDep,
424
- islandCount: islands.length,
425
- sharedCount: shared.length,
426
- dedupeSavings,
427
- version: 1,
428
- generatedAt: new Date().toISOString(),
429
- };
430
-
431
- return { islands, shared, summary };
432
- }
433
-
434
- // ============================================================================
435
- // Serializers
436
- // ============================================================================
437
-
438
- /**
439
- * Write `report.json` + `report.html` to `<rootDir>/.mandu/analyze/`.
440
- *
441
- * Returns the absolute paths of both files so the CLI can print them.
442
- * Callers that want JSON only can skip the HTML step via `{ htmlPath: null }`.
443
- *
444
- * Phase 18.φ — `opts.budget` is an optional pre-computed budget report
445
- * that, when present, renders a budget-bar section in the HTML output
446
- * and is serialised alongside `report.json` as `report.budget`.
447
- */
448
- export async function writeAnalyzeReport(
449
- rootDir: string,
450
- report: AnalyzeReport,
451
- opts: { html?: boolean; budget?: BudgetReport | null } = {}
452
- ): Promise<{ jsonPath: string; htmlPath: string | null }> {
453
- const outDir = path.join(rootDir, ".mandu", "analyze");
454
- await fs.mkdir(outDir, { recursive: true });
455
- const jsonPath = path.join(outDir, "report.json");
456
- const jsonPayload = opts.budget ? { ...report, budget: opts.budget } : report;
457
- await fs.writeFile(jsonPath, JSON.stringify(jsonPayload, null, 2), "utf8");
458
-
459
- let htmlPath: string | null = null;
460
- if (opts.html !== false) {
461
- htmlPath = path.join(outDir, "report.html");
462
- await fs.writeFile(htmlPath, renderAnalyzeHtml(report, opts.budget ?? null), "utf8");
463
- }
464
- return { jsonPath, htmlPath };
465
- }
466
-
467
- // ============================================================================
468
- // HTML report
469
- // ============================================================================
470
-
471
- /**
472
- * Render a self-contained single-file HTML report. No external CDN, no d3,
473
- * no webpack-bundle-analyzer — just inline SVG + a hand-rolled squarify
474
- * treemap. The result is ~12-30 KB for typical projects.
475
- *
476
- * Design notes:
477
- * - Dark theme + monospace font so stack-trace-like paths stay readable.
478
- * - Clicking an island rectangle drills into its module breakdown.
479
- * - ESC / click-outside returns to the island view.
480
- * - All SVG elements are generated server-side — the client script only
481
- * toggles visibility. This keeps the report working even with JS
482
- * disabled (you lose drill-down, but the island treemap still renders).
483
- */
484
- export function renderAnalyzeHtml(
485
- report: AnalyzeReport,
486
- budget: BudgetReport | null = null
487
- ): string {
488
- const { islands, shared, summary } = report;
489
-
490
- // ── Island-level treemap ─────────────────────────────────────────────────
491
- const VIEW_W = 960;
492
- const VIEW_H = 480;
493
- const islandRects = squarify(
494
- islands.map((i) => ({ name: i.name, value: Math.max(i.totalRaw, 1), island: i })),
495
- VIEW_W,
496
- VIEW_H
497
- );
498
-
499
- const islandSvg = islandRects
500
- .map((r, idx) => {
501
- const color = ISLAND_PALETTE[idx % ISLAND_PALETTE.length];
502
- const island = r.data.island;
503
- const label = `${r.data.name}\n${fmtBytes(island.totalRaw)} / ${fmtBytes(island.totalGz)} gz`;
504
- const canLabel = r.w > 60 && r.h > 28;
505
- // XSS note: island names come from user route ids / island file
506
- // basenames, so they are not fully trusted. We never embed the raw
507
- // name in a JS string context — the drill target is carried on
508
- // `data-drill` and a single delegated event listener reads it via
509
- // `Element.dataset`, which does zero string parsing.
510
- return `
511
- <g class="island-cell" data-drill="${escAttr(r.data.name)}" tabindex="0" role="button" style="cursor:pointer">
512
- <rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" fill="${color}" stroke="#0a0f14" stroke-width="1"/>
513
- ${
514
- canLabel
515
- ? `<text x="${r.x + 8}" y="${r.y + 18}" fill="#fff" font-size="12" font-family="ui-monospace,Menlo,Consolas,monospace">${escText(r.data.name)}</text>
516
- <text x="${r.x + 8}" y="${r.y + 34}" fill="#cbd5e1" font-size="10" font-family="ui-monospace,Menlo,Consolas,monospace">${fmtBytes(
517
- island.totalRaw
518
- )} / ${fmtBytes(island.totalGz)} gz</text>`
519
- : ""
520
- }
521
- <title>${escText(label)}</title>
522
- </g>`;
523
- })
524
- .join("");
525
-
526
- // ── Per-island drill-down panels ─────────────────────────────────────────
527
- //
528
- // DOM id is `drill-<index>` (a safe numeric) rather than `drill-<name>`
529
- // so the name never enters an id-attribute context. The name itself is
530
- // still shown via the `data-drill` attribute matched by the client
531
- // script, both values normalised by `escAttr` / `escText`.
532
- const drillPanels = islands
533
- .map((island, idx) => {
534
- const safeId = `drill-${idx}`;
535
- if (island.modules.length === 0) {
536
- return `<section class="drill" id="${safeId}" data-drill="${escAttr(island.name)}" hidden>
537
- <h3>${escText(island.name)}</h3>
538
- <p class="muted">No sourcemap available for this bundle. Re-run with <code>mandu build --sourcemap --analyze</code> to see per-module breakdown.</p>
539
- </section>`;
540
- }
541
- const maxSize = Math.max(...island.modules.map((m) => m.size), 1);
542
- const rows = island.modules
543
- .map((m) => {
544
- const pct = (m.size / island.totalRaw) * 100;
545
- const barW = (m.size / maxSize) * 100;
546
- return `<tr>
547
- <td class="mod-path" title="${escAttr(m.path)}">${escText(m.path)}</td>
548
- <td class="num">${fmtBytes(m.size)}</td>
549
- <td class="num muted">~${fmtBytes(m.gz)}</td>
550
- <td class="num">${pct.toFixed(1)}%</td>
551
- <td class="bar"><span style="width:${barW.toFixed(2)}%"></span></td>
552
- </tr>`;
553
- })
554
- .join("");
555
- return `<section class="drill" id="${safeId}" data-drill="${escAttr(island.name)}" hidden>
556
- <h3>${escText(island.name)} <span class="muted">${fmtBytes(island.totalRaw)} raw · ${fmtBytes(island.totalGz)} gz · shared: ${island.shared.join(", ") || "—"}</span></h3>
557
- <table class="modtable">
558
- <thead><tr><th>module</th><th>size</th><th>~gz</th><th>%</th><th></th></tr></thead>
559
- <tbody>${rows}</tbody>
560
- </table>
561
- </section>`;
562
- })
563
- .join("");
564
-
565
- // ── Shared-chunks table ──────────────────────────────────────────────────
566
- const sharedRows = shared
567
- .map(
568
- (c) => `<tr>
569
- <td>${escText(c.id)}</td>
570
- <td class="num">${fmtBytes(c.size)}</td>
571
- <td class="num">${fmtBytes(c.gz)}</td>
572
- <td>${c.usedBy.length}</td>
573
- <td class="muted">${escText(c.usedBy.join(", ") || "—")}</td>
574
- </tr>`
575
- )
576
- .join("");
577
-
578
- // ── Summary cards ────────────────────────────────────────────────────────
579
- const summaryCards = `
580
- <div class="card"><div class="label">Total raw</div><div class="value">${fmtBytes(summary.totalRaw)}</div></div>
581
- <div class="card"><div class="label">Total gzip</div><div class="value">${fmtBytes(summary.totalGz)}</div></div>
582
- <div class="card"><div class="label">Islands</div><div class="value">${summary.islandCount}</div></div>
583
- <div class="card"><div class="label">Shared chunks</div><div class="value">${summary.sharedCount}</div></div>
584
- <div class="card"><div class="label">Largest island</div><div class="value">${
585
- summary.largestIsland
586
- ? `${escText(summary.largestIsland.name)} (${fmtBytes(summary.largestIsland.totalRaw)})`
587
- : "—"
588
- }</div></div>
589
- <div class="card"><div class="label">Heaviest dep</div><div class="value">${
590
- summary.heaviestDep
591
- ? `${escText(summary.heaviestDep.path)} (${fmtBytes(summary.heaviestDep.size)})`
592
- : "—"
593
- }</div></div>
594
- <div class="card"><div class="label">Dedupe savings</div><div class="value">${fmtBytes(summary.dedupeSavings)}</div></div>
595
- `;
596
-
597
- // ── Phase 18.φ — Budget bar section ──────────────────────────────────────
598
- //
599
- // Renders one horizontal bar per island when a budget was evaluated,
600
- // coloured by `BudgetStatus`: green (within), yellow (within 10% of
601
- // limit), red (exceeded). The bar width is proportional to
602
- // `island.gz / gzLimit` (or `raw / rawLimit` if gzLimit is null).
603
- // When every axis is unconstrained the bar is hidden with a muted "—"
604
- // placeholder. Matches the "red/yellow/green" spec in Phase 18.φ.
605
- const budgetSection = budget
606
- ? renderBudgetSection(budget)
607
- : "";
608
-
609
- return `<!doctype html>
610
- <html lang="en">
611
- <head>
612
- <meta charset="utf-8">
613
- <meta name="viewport" content="width=device-width,initial-scale=1">
614
- <title>Mandu Bundle Analyzer</title>
615
- <style>
616
- :root { color-scheme: dark; }
617
- html, body { margin: 0; padding: 0; background: #0a0f14; color: #e5e7eb; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
618
- header { padding: 16px 20px; border-bottom: 1px solid #1f2937; display: flex; align-items: baseline; gap: 12px; }
619
- header h1 { font-size: 16px; margin: 0; font-weight: 600; color: #f3f4f6; }
620
- header .meta { color: #64748b; font-size: 11px; }
621
- main { padding: 20px; max-width: 1080px; margin: 0 auto; }
622
- h2 { font-size: 14px; color: #9ca3af; font-weight: 600; margin: 24px 0 8px; text-transform: uppercase; letter-spacing: 0.08em; }
623
- .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }
624
- .card { background: #111827; border: 1px solid #1f2937; border-radius: 4px; padding: 10px 12px; }
625
- .card .label { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
626
- .card .value { color: #f3f4f6; font-size: 14px; margin-top: 4px; word-break: break-all; }
627
- svg.treemap { background: #0f172a; border: 1px solid #1f2937; border-radius: 4px; display: block; width: 100%; height: auto; }
628
- .island-cell:hover rect { stroke: #38bdf8; stroke-width: 2; }
629
- .island-cell:focus rect { stroke: #38bdf8; stroke-width: 2; outline: none; }
630
- table { width: 100%; border-collapse: collapse; }
631
- th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #1f2937; font-size: 12px; }
632
- th { color: #64748b; font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
633
- td.num { text-align: right; font-variant-numeric: tabular-nums; }
634
- td.mod-path { max-width: 480px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
635
- td.bar { width: 120px; }
636
- td.bar span { display: block; height: 8px; background: linear-gradient(90deg, #38bdf8, #0ea5e9); border-radius: 2px; }
637
- .muted { color: #64748b; }
638
- .modtable tr:hover td { background: #0f172a; }
639
- section.drill { margin-top: 16px; background: #0f172a; border: 1px solid #1f2937; border-radius: 4px; padding: 14px; }
640
- section.drill h3 { margin: 0 0 10px; font-size: 13px; color: #e5e7eb; font-weight: 600; }
641
- code { background: #1f2937; padding: 1px 4px; border-radius: 2px; color: #93c5fd; }
642
- button.close { background: #1f2937; color: #e5e7eb; border: 1px solid #374151; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-family: inherit; font-size: 11px; }
643
- button.close:hover { background: #374151; }
644
- .drill-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
645
- /* Phase 18.φ — budget bars */
646
- .budget-row { display: grid; grid-template-columns: 180px 1fr 160px; gap: 10px; align-items: center; padding: 4px 0; border-bottom: 1px solid #1f2937; }
647
- .budget-row:last-child { border-bottom: none; }
648
- .budget-name { font-size: 12px; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
649
- .budget-meta { font-size: 11px; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums; }
650
- .budget-bar-track { position: relative; height: 10px; background: #0f172a; border: 1px solid #1f2937; border-radius: 2px; overflow: hidden; }
651
- .budget-bar-fill { height: 100%; border-radius: 2px; }
652
- .budget-bar-fill.within { background: linear-gradient(90deg, #16a34a, #22c55e); }
653
- .budget-bar-fill.within10 { background: linear-gradient(90deg, #ca8a04, #eab308); }
654
- .budget-bar-fill.exceeded { background: linear-gradient(90deg, #b91c1c, #ef4444); }
655
- .budget-bar-fill.unbounded { background: repeating-linear-gradient(45deg, #1f2937, #1f2937 4px, #0f172a 4px, #0f172a 8px); }
656
- .budget-legend { display: inline-flex; gap: 14px; font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
657
- .budget-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
658
- .budget-legend .lg-within::before { background: #22c55e; }
659
- .budget-legend .lg-within10::before { background: #eab308; }
660
- .budget-legend .lg-exceeded::before { background: #ef4444; }
661
- .budget-mode { display: inline-block; font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 2px; border: 1px solid #374151; color: #cbd5e1; margin-left: 8px; letter-spacing: 0.04em; }
662
- .budget-mode.error { background: #7f1d1d; border-color: #991b1b; color: #fecaca; }
663
- .budget-mode.warning { background: #713f12; border-color: #854d0e; color: #fde68a; }
664
- </style>
665
- </head>
666
- <body>
667
- <header>
668
- <h1>Mandu Bundle Analyzer</h1>
669
- <span class="meta">generated ${escText(summary.generatedAt)} · schema v${summary.version}</span>
670
- </header>
671
- <main>
672
- <h2>Summary</h2>
673
- <div class="cards">${summaryCards}</div>
674
-
675
- ${budgetSection}
676
-
677
- <h2>Islands (click to drill in)</h2>
678
- <svg class="treemap" viewBox="0 0 ${VIEW_W} ${VIEW_H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Island bundle treemap">
679
- ${islandSvg || `<text x="20" y="30" fill="#64748b">No islands to display.</text>`}
680
- </svg>
681
-
682
- <div id="drill-host">${drillPanels}</div>
683
-
684
- <h2>Shared chunks</h2>
685
- <table>
686
- <thead><tr><th>id</th><th class="num">size</th><th class="num">gzip</th><th>used by #</th><th>islands</th></tr></thead>
687
- <tbody>${sharedRows || `<tr><td colspan="5" class="muted">(no shared chunks — pure-SSR project?)</td></tr>`}</tbody>
688
- </table>
689
-
690
- <h2>Islands</h2>
691
- <table>
692
- <thead><tr><th>name</th><th class="num">raw</th><th class="num">gzip</th><th>priority</th><th>shared</th><th>modules</th></tr></thead>
693
- <tbody>
694
- ${islands
695
- .map(
696
- (i) => `<tr>
697
- <td><a href="#" data-drill="${escAttr(i.name)}" class="drill-link">${escText(i.name)}</a></td>
698
- <td class="num">${fmtBytes(i.totalRaw)}</td>
699
- <td class="num">${fmtBytes(i.totalGz)}</td>
700
- <td>${escText(i.priority)}</td>
701
- <td class="muted">${escText(i.shared.join(", ") || "—")}</td>
702
- <td class="num">${i.modules.length}</td>
703
- </tr>`
704
- )
705
- .join("")}
706
- </tbody>
707
- </table>
708
- </main>
709
- <script>
710
- (function () {
711
- var current = null;
712
- function drillTo(name) {
713
- if (!name) return;
714
- // Find the section by matching data-drill; IDs use a numeric index
715
- // so we never build a selector from potentially unsafe user text.
716
- var sections = document.querySelectorAll("section.drill");
717
- var next = null;
718
- for (var i = 0; i < sections.length; i++) {
719
- if (sections[i].getAttribute("data-drill") === name) { next = sections[i]; break; }
720
- }
721
- if (!next) return;
722
- if (current && current !== next) current.hidden = true;
723
- next.hidden = false;
724
- current = next;
725
- next.scrollIntoView({ behavior: "smooth", block: "start" });
726
- }
727
- document.addEventListener("click", function (e) {
728
- var t = e.target;
729
- while (t && t.nodeType === 1) {
730
- var d = t.getAttribute && t.getAttribute("data-drill");
731
- if (d) { e.preventDefault(); drillTo(d); return; }
732
- t = t.parentNode;
733
- }
734
- });
735
- document.addEventListener("keydown", function (e) {
736
- if (e.key === "Escape" && current) { current.hidden = true; current = null; }
737
- });
738
- })();
739
- </script>
740
- </body>
741
- </html>`;
742
- }
743
-
744
- // ============================================================================
745
- // Helpers — formatting + squarify
746
- // ============================================================================
747
-
748
- /**
749
- * Phase 18.φ — render the budget-bar block. Colour-codes each island
750
- * by {@link BudgetReport.BudgetStatus} and the project-wide total (when
751
- * present). Islands without any applicable limit render a diagonal-
752
- * hatched "unbounded" bar so the user sees the row but understands
753
- * nothing is enforced. Self-contained: no JS, no external assets.
754
- */
755
- function renderBudgetSection(budget: BudgetReport): string {
756
- const modeClass = budget.mode === "error" ? "error" : "warning";
757
- const rows = budget.islands
758
- .map((i) => renderBudgetRow(i.name, i.raw, i.gz, i.rawLimit, i.gzLimit, i.status))
759
- .join("");
760
- const totalRow = budget.total
761
- ? renderBudgetRow(
762
- "<project total>",
763
- budget.total.raw,
764
- budget.total.gz,
765
- budget.total.rawLimit,
766
- budget.total.gzLimit,
767
- budget.total.status
768
- )
769
- : "";
770
- const exceedHeadline = budget.hasExceeded
771
- ? ` · <span style="color:#fca5a5">${budget.exceededCount} over limit</span>`
772
- : "";
773
- return `
774
- <h2>Bundle budget <span class="budget-mode ${modeClass}">${escText(budget.mode)}</span></h2>
775
- <div class="budget-legend">
776
- <span class="lg-within">within</span>
777
- <span class="lg-within10">approaching (≥90%)</span>
778
- <span class="lg-exceeded">exceeded</span>
779
- </div>
780
- <p class="muted" style="margin:0 0 10px">
781
- ${budget.withinCount}/${budget.islandCount} islands within limits${exceedHeadline}
782
- </p>
783
- <div class="card" style="padding:12px 14px">
784
- ${rows}
785
- ${totalRow}
786
- </div>`;
787
- }
788
-
789
- function renderBudgetRow(
790
- name: string,
791
- raw: number,
792
- gz: number,
793
- rawLimit: number | null,
794
- gzLimit: number | null,
795
- status: "within" | "within10" | "exceeded"
796
- ): string {
797
- // Prefer gz-axis progress bar when a gz limit exists (the 90%-of-the-
798
- // time-useful axis); fall back to raw when only raw is constrained.
799
- let pct = 0;
800
- let barClass: string = status;
801
- let meta: string;
802
- if (gzLimit !== null) {
803
- pct = Math.min(100, Math.max(0, (gz / Math.max(gzLimit, 1)) * 100));
804
- meta = `${fmtBytes(gz)} / ${fmtBytes(gzLimit)} gz`;
805
- } else if (rawLimit !== null) {
806
- pct = Math.min(100, Math.max(0, (raw / Math.max(rawLimit, 1)) * 100));
807
- meta = `${fmtBytes(raw)} / ${fmtBytes(rawLimit)} raw`;
808
- } else {
809
- barClass = "unbounded";
810
- pct = 100;
811
- meta = `${fmtBytes(gz)} gz · no limit`;
812
- }
813
- return `
814
- <div class="budget-row">
815
- <div class="budget-name" title="${escAttr(name)}">${escText(name)}</div>
816
- <div class="budget-bar-track"><div class="budget-bar-fill ${barClass}" style="width:${pct.toFixed(1)}%"></div></div>
817
- <div class="budget-meta">${meta}</div>
818
- </div>`;
819
- }
820
-
821
- /** Human-readable byte formatter. Matches the style used by `printBundleStats`. */
822
- export function fmtBytes(n: number): string {
823
- if (!Number.isFinite(n) || n <= 0) return "0 B";
824
- if (n < 1024) return `${n} B`;
825
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
826
- return `${(n / (1024 * 1024)).toFixed(2)} MB`;
827
- }
828
-
829
- function escText(s: string): string {
830
- return s
831
- .replace(/&/g, "&amp;")
832
- .replace(/</g, "&lt;")
833
- .replace(/>/g, "&gt;");
834
- }
835
-
836
- function escAttr(s: string): string {
837
- return escText(s).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
838
- }
839
-
840
- /** Distinct dark-theme colour ramp, no dependency on d3-scale. */
841
- const ISLAND_PALETTE = [
842
- "#1e3a8a", "#155e75", "#166534", "#854d0e", "#7c2d12",
843
- "#581c87", "#831843", "#0f766e", "#3730a3", "#92400e",
844
- "#064e3b", "#6b21a8", "#134e4a", "#7f1d1d", "#1e40af",
845
- ];
846
-
847
- interface SquarifyInput<T> {
848
- name: string;
849
- value: number;
850
- island: T;
851
- }
852
- interface SquarifyRect<T> {
853
- x: number;
854
- y: number;
855
- w: number;
856
- h: number;
857
- data: SquarifyInput<T>;
858
- }
859
-
860
- /**
861
- * Minimal squarify treemap layout.
862
- *
863
- * Classic Bruls-Huijing-van-Wijk algorithm — pack rectangles into a
864
- * bounding box to minimise worst-case aspect ratio. Implemented in-repo so
865
- * the HTML report has zero runtime deps. ~60 LOC.
866
- *
867
- * Input is pre-sorted descending by `value`. We maintain a current "row"
868
- * (strip) and keep adding rectangles until the worst aspect ratio would
869
- * increase if we added the next one; at that point we lay out the row
870
- * and start a new one on the remaining area.
871
- */
872
- function squarify<T>(
873
- input: SquarifyInput<T>[],
874
- width: number,
875
- height: number
876
- ): SquarifyRect<T>[] {
877
- const sorted = [...input].sort((a, b) => b.value - a.value);
878
- const total = sorted.reduce((s, n) => s + n.value, 0);
879
- if (total <= 0 || sorted.length === 0) return [];
880
-
881
- // Scale values so the sum equals width * height.
882
- const scale = (width * height) / total;
883
- const scaled = sorted.map((n) => ({ ...n, area: n.value * scale }));
884
-
885
- const result: SquarifyRect<T>[] = [];
886
-
887
- function worst(row: number[], side: number): number {
888
- if (row.length === 0) return Infinity;
889
- const sum = row.reduce((s, v) => s + v, 0);
890
- const rowMax = Math.max(...row);
891
- const rowMin = Math.min(...row);
892
- const s2 = side * side;
893
- const sum2 = sum * sum;
894
- return Math.max((s2 * rowMax) / sum2, sum2 / (s2 * rowMin));
895
- }
896
-
897
- let x = 0;
898
- let y = 0;
899
- let w = width;
900
- let h = height;
901
- let i = 0;
902
-
903
- while (i < scaled.length) {
904
- const row: number[] = [];
905
- const rowData: typeof scaled = [];
906
- const side = Math.min(w, h);
907
- // Build row.
908
- while (i < scaled.length) {
909
- const next = scaled[i].area;
910
- const candidate = [...row, next];
911
- if (row.length === 0 || worst(candidate, side) <= worst(row, side)) {
912
- row.push(next);
913
- rowData.push(scaled[i]);
914
- i++;
915
- } else {
916
- break;
917
- }
918
- }
919
- // Lay out row.
920
- const rowSum = row.reduce((s, v) => s + v, 0);
921
- const rowThickness = rowSum / side;
922
- if (w <= h) {
923
- // Horizontal strip along the top.
924
- let cx = x;
925
- for (let r = 0; r < row.length; r++) {
926
- const rw = row[r] / rowThickness;
927
- result.push({
928
- x: cx,
929
- y,
930
- w: rw,
931
- h: rowThickness,
932
- data: rowData[r],
933
- });
934
- cx += rw;
935
- }
936
- y += rowThickness;
937
- h -= rowThickness;
938
- } else {
939
- // Vertical strip along the left.
940
- let cy = y;
941
- for (let r = 0; r < row.length; r++) {
942
- const rh = row[r] / rowThickness;
943
- result.push({
944
- x,
945
- y: cy,
946
- w: rowThickness,
947
- h: rh,
948
- data: rowData[r],
949
- });
950
- cy += rh;
951
- }
952
- x += rowThickness;
953
- w -= rowThickness;
954
- }
955
- }
956
-
957
- return result;
958
- }
1
+ /**
2
+ * Phase 18.η — Bundle analyzer.
3
+ *
4
+ * Post-processes `.mandu/client/` output + the emitted `BundleManifest` into
5
+ * a structured JSON report and a self-contained HTML treemap. Intended for
6
+ * `mandu build --analyze` and for programmatic use from deploy scripts / CI.
7
+ *
8
+ * ── What this module produces ────────────────────────────────────────────────
9
+ *
10
+ * analyzeBundle(rootDir, manifest) → AnalyzeReport
11
+ * {
12
+ * islands: [
13
+ * {
14
+ * name: "home" // island route id
15
+ * js: "/.mandu/client/home.island.js"
16
+ * totalRaw: 123456
17
+ * totalGz: 42017
18
+ * priority: "visible"
19
+ * shared: ["runtime","vendor"] // chunks referenced
20
+ * modules: [ { path, size, gz } ... up to top-20 ]
21
+ * }
22
+ * ],
23
+ * shared: [
24
+ * {
25
+ * id: "vendor"
26
+ * js: "/.mandu/client/_vendor.js"
27
+ * size: 258912
28
+ * gz: 87120
29
+ * usedBy: ["home","dashboard"]
30
+ * }
31
+ * ],
32
+ * summary: {
33
+ * totalRaw, totalGz,
34
+ * largestIsland: { name, totalRaw },
35
+ * heaviestDep: { path, size } // heaviest module seen anywhere
36
+ * islandCount, sharedCount,
37
+ * }
38
+ * }
39
+ *
40
+ * ── Module-level breakdown ──────────────────────────────────────────────────
41
+ *
42
+ * When `--sourcemap` was passed to `mandu build`, each JS output gets an
43
+ * external `.map` file next to it. We parse the `sources[]` array + the
44
+ * `sourcesContent[]` (if present) to derive per-source-file byte sizes. That
45
+ * gives us the "top-20 heaviest modules per island" drill-down that the HTML
46
+ * report renders as a second-level treemap.
47
+ *
48
+ * When sourcemaps are absent we degrade gracefully:
49
+ * - `modules: []` on every island entry
50
+ * - `heaviestDep: { path: "<sourcemap unavailable>", size: 0 }`
51
+ * - HTML report still renders the island-level treemap
52
+ *
53
+ * ── Design choices ──────────────────────────────────────────────────────────
54
+ *
55
+ * - Zero runtime deps. `zlib` (`Bun.gzipSync`) + `fs/promises` only.
56
+ * The HTML report inlines its own squarify treemap (~150 LOC) so the
57
+ * output file is fully portable — drag-and-drop into any browser, no
58
+ * CDN. This is a deliberate no-d3 choice (see `report.html` comment).
59
+ * - Pure function. `analyzeBundle()` does filesystem reads but does not
60
+ * write. Serialization (`writeReport()` / `renderHtml()`) is separate so
61
+ * tests can assert on the report shape without touching disk.
62
+ * - Stable output. Islands are sorted by `totalRaw DESC`, shared chunks by
63
+ * size DESC, modules within an island by size DESC. CI snapshots won't
64
+ * churn on Map iteration order.
65
+ *
66
+ * ── What this module deliberately does NOT do ───────────────────────────────
67
+ *
68
+ * - No tree-shaking suggestions. That's an optimizer concern; the report
69
+ * is purely descriptive. Agent η's scope ends at "show the developer
70
+ * what's in their bundle".
71
+ * - No historical / delta comparison. A separate command (future
72
+ * `mandu build --analyze-against=prev.json`) can diff two reports.
73
+ * - No network. Absolutely nothing is fetched. The report is generated
74
+ * from files on disk produced by `buildClientBundles()`.
75
+ */
76
+
77
+ import fs from "fs/promises";
78
+ import path from "path";
79
+ import zlib from "zlib";
80
+
81
+ import type { BundleManifest } from "./types";
82
+ import type { BudgetReport } from "./budget";
83
+
84
+ // ============================================================================
85
+ // Types
86
+ // ============================================================================
87
+
88
+ export interface AnalyzeModule {
89
+ /** Source path as emitted by the sourcemap (e.g. `node_modules/react/index.js`). */
90
+ path: string;
91
+ /** Raw byte contribution attributed to this module inside the final bundle. */
92
+ size: number;
93
+ /** Estimated gzip size (proportional scaling from bundle gz, since gz is non-additive). */
94
+ gz: number;
95
+ }
96
+
97
+ export interface AnalyzeIsland {
98
+ /** Island / route id (e.g. `home`, `dashboard`). */
99
+ name: string;
100
+ /** Absolute `/.mandu/client/<file>.js` path from the manifest. */
101
+ js: string;
102
+ /** Raw (uncompressed) bytes of the island's JS output. */
103
+ totalRaw: number;
104
+ /** Real gzip size of the island's JS output — not an estimate. */
105
+ totalGz: number;
106
+ /** Hydration priority propagated from the manifest. */
107
+ priority: "immediate" | "visible" | "idle" | "interaction";
108
+ /** Shared-chunk ids this island depends on (e.g. `["runtime", "vendor"]`). */
109
+ shared: string[];
110
+ /** Top-20 heaviest source modules (empty when sourcemaps are unavailable). */
111
+ modules: AnalyzeModule[];
112
+ }
113
+
114
+ export interface AnalyzeSharedChunk {
115
+ /** Logical id — `runtime`, `vendor`, `router`, `fastRefresh.runtime`, etc. */
116
+ id: string;
117
+ /** `/.mandu/client/...` path from the manifest. */
118
+ js: string;
119
+ /** Raw byte size of the chunk. */
120
+ size: number;
121
+ /** Gzip byte size of the chunk. */
122
+ gz: number;
123
+ /** Every island name that transitively depends on this chunk. */
124
+ usedBy: string[];
125
+ }
126
+
127
+ export interface AnalyzeSummary {
128
+ /** Sum of every island + every shared chunk, raw bytes. */
129
+ totalRaw: number;
130
+ /** Sum of every island + every shared chunk, gzip bytes. */
131
+ totalGz: number;
132
+ /** The single heaviest island (by raw bytes). */
133
+ largestIsland: { name: string; totalRaw: number } | null;
134
+ /** The heaviest source module across all islands (source-map derived). */
135
+ heaviestDep: { path: string; size: number } | null;
136
+ /** Island count — convenience for CLI table rendering. */
137
+ islandCount: number;
138
+ /** Shared-chunk count. */
139
+ sharedCount: number;
140
+ /** Deduplication savings: bytes that would have been duplicated if each
141
+ * island inlined its shared deps instead of referencing them. */
142
+ dedupeSavings: number;
143
+ /** Report schema version. Bump on breaking shape changes. */
144
+ version: 1;
145
+ /** ISO timestamp when the report was generated. */
146
+ generatedAt: string;
147
+ }
148
+
149
+ export interface AnalyzeReport {
150
+ islands: AnalyzeIsland[];
151
+ shared: AnalyzeSharedChunk[];
152
+ summary: AnalyzeSummary;
153
+ }
154
+
155
+ // ============================================================================
156
+ // Size primitives
157
+ // ============================================================================
158
+
159
+ /**
160
+ * Strip the leading `/.mandu/client/` prefix and join against `<rootDir>/.mandu/client/`.
161
+ *
162
+ * The bundle manifest stores URLs as browser-relative absolute paths
163
+ * (`/.mandu/client/<file>`). The analyzer runs on disk, so we need to flip
164
+ * the URL into a `fs` path. Returns `null` for any URL that does not match
165
+ * the expected shape — defensive against `data:` / `http:` / tampered
166
+ * manifests. (The manifest is already Zod-validated at build time, but
167
+ * belt-and-braces: one more check means this file is safe to feed an
168
+ * unvalidated manifest from an old build.)
169
+ */
170
+ function urlToFsPath(rootDir: string, url: string | undefined): string | null {
171
+ if (!url || typeof url !== "string") return null;
172
+ if (!url.startsWith("/.mandu/client/")) return null;
173
+ const rel = url.slice("/".length); // keep `.mandu/client/...`
174
+ return path.join(rootDir, rel);
175
+ }
176
+
177
+ /**
178
+ * Read a file and return both raw and gzip byte counts. Returns `{ raw: 0,
179
+ * gz: 0 }` when the file is missing — the bundler may have emitted a
180
+ * stub entry (e.g. `shared.fastRefresh` in prod) and we don't want the
181
+ * report to crash on one missing file.
182
+ */
183
+ async function measureFile(absPath: string): Promise<{ raw: number; gz: number }> {
184
+ try {
185
+ const buf = await fs.readFile(absPath);
186
+ const gz = zlib.gzipSync(buf, { level: 9 });
187
+ return { raw: buf.byteLength, gz: gz.byteLength };
188
+ } catch {
189
+ return { raw: 0, gz: 0 };
190
+ }
191
+ }
192
+
193
+ // ============================================================================
194
+ // Sourcemap parsing
195
+ // ============================================================================
196
+
197
+ interface SourceMapV3 {
198
+ version: 3;
199
+ sources: string[];
200
+ sourcesContent?: (string | null)[];
201
+ mappings?: string;
202
+ file?: string;
203
+ }
204
+
205
+ /**
206
+ * Parse the sourcemap file associated with a built bundle (same path + `.map`).
207
+ *
208
+ * Returns a `AnalyzeModule[]` aggregated by source path, largest first,
209
+ * truncated at `limit` entries. Size attribution uses `sourcesContent`
210
+ * byte length as the best available proxy. When sourcesContent is absent
211
+ * (minified-only map) we return an empty array — a stub rather than
212
+ * misleading data.
213
+ *
214
+ * Gzip per module is estimated as `size * (bundleGz / bundleRaw)` — a
215
+ * linear proportional attribution. Gzip is not actually additive (two
216
+ * identical modules don't double the gz weight), so this is an
217
+ * approximation; the HTML treemap labels it as "~gz" to signal that.
218
+ */
219
+ async function readTopModules(
220
+ jsAbsPath: string,
221
+ bundleRaw: number,
222
+ bundleGz: number,
223
+ limit = 20
224
+ ): Promise<AnalyzeModule[]> {
225
+ const mapPath = `${jsAbsPath}.map`;
226
+ let text: string;
227
+ try {
228
+ text = await fs.readFile(mapPath, "utf8");
229
+ } catch {
230
+ return [];
231
+ }
232
+ let map: SourceMapV3;
233
+ try {
234
+ map = JSON.parse(text) as SourceMapV3;
235
+ } catch {
236
+ return [];
237
+ }
238
+ if (!Array.isArray(map.sources) || !Array.isArray(map.sourcesContent)) {
239
+ return [];
240
+ }
241
+
242
+ // One source can appear multiple times in a concatenated chunk; we
243
+ // aggregate by path. Use a Map so insertion order is preserved for ties.
244
+ const sizeByPath = new Map<string, number>();
245
+ for (let i = 0; i < map.sources.length; i++) {
246
+ const src = map.sources[i];
247
+ const content = map.sourcesContent[i];
248
+ if (typeof content !== "string") continue;
249
+ // Normalise `../../../node_modules/react/index.js` → `react/index.js`
250
+ // so the treemap legend groups every `react/*` under the same prefix.
251
+ const norm = normalizeSourcePath(src);
252
+ sizeByPath.set(norm, (sizeByPath.get(norm) ?? 0) + content.length);
253
+ }
254
+
255
+ const gzRatio = bundleRaw > 0 ? bundleGz / bundleRaw : 0;
256
+ return [...sizeByPath.entries()]
257
+ .sort((a, b) => b[1] - a[1])
258
+ .slice(0, limit)
259
+ .map(([p, size]) => ({
260
+ path: p,
261
+ size,
262
+ gz: Math.round(size * gzRatio),
263
+ }));
264
+ }
265
+
266
+ /**
267
+ * Collapse a Bun.build source path to something recognizable.
268
+ *
269
+ * Examples:
270
+ * `../../../node_modules/react/index.js` → `react/index.js`
271
+ * `../../src/app/home/page.tsx` → `src/app/home/page.tsx`
272
+ * `bun:react-dom` → `bun:react-dom`
273
+ */
274
+ export function normalizeSourcePath(raw: string): string {
275
+ if (!raw) return "<unknown>";
276
+ if (raw.startsWith("bun:")) return raw;
277
+ // Normalise Windows slashes FIRST so the relative-prefix strip below
278
+ // catches `..\src\foo` as well as `../src/foo`.
279
+ let p = raw.replace(/\\/g, "/");
280
+ // Strip leading `./` and `../` segments.
281
+ p = p.replace(/^(\.\.\/|\.\/)+/, "");
282
+ // `node_modules/foo/...` → `foo/...` (keeps dep identity, drops pnpm noise)
283
+ const nmIdx = p.lastIndexOf("node_modules/");
284
+ if (nmIdx !== -1) {
285
+ p = p.slice(nmIdx + "node_modules/".length);
286
+ }
287
+ return p;
288
+ }
289
+
290
+ // ============================================================================
291
+ // Main analyzer
292
+ // ============================================================================
293
+
294
+ /**
295
+ * Read every artifact referenced by the manifest and build the report.
296
+ *
297
+ * `rootDir` is the project root (the one containing `.mandu/`).
298
+ */
299
+ export async function analyzeBundle(
300
+ rootDir: string,
301
+ manifest: BundleManifest
302
+ ): Promise<AnalyzeReport> {
303
+ // ── Step 1: Measure shared chunks ────────────────────────────────────────
304
+ const shared: AnalyzeSharedChunk[] = [];
305
+ const sharedUrls: { id: string; url: string }[] = [];
306
+ if (manifest.shared?.runtime) sharedUrls.push({ id: "runtime", url: manifest.shared.runtime });
307
+ if (manifest.shared?.vendor) sharedUrls.push({ id: "vendor", url: manifest.shared.vendor });
308
+ if (manifest.shared?.router) sharedUrls.push({ id: "router", url: manifest.shared.router });
309
+ if (manifest.shared?.fastRefresh?.runtime) {
310
+ sharedUrls.push({
311
+ id: "fastRefresh.runtime",
312
+ url: manifest.shared.fastRefresh.runtime,
313
+ });
314
+ }
315
+ if (manifest.shared?.fastRefresh?.glue) {
316
+ sharedUrls.push({ id: "fastRefresh.glue", url: manifest.shared.fastRefresh.glue });
317
+ }
318
+
319
+ const sharedById = new Map<string, AnalyzeSharedChunk>();
320
+ for (const { id, url } of sharedUrls) {
321
+ const abs = urlToFsPath(rootDir, url);
322
+ if (!abs) continue;
323
+ const { raw, gz } = await measureFile(abs);
324
+ const entry: AnalyzeSharedChunk = { id, js: url, size: raw, gz, usedBy: [] };
325
+ shared.push(entry);
326
+ sharedById.set(id, entry);
327
+ }
328
+
329
+ // ── Step 2: Walk islands / bundles ───────────────────────────────────────
330
+ //
331
+ // Both `manifest.bundles` (route-level) and `manifest.islands` (per-island
332
+ // code-split) describe client JS entrypoints. We treat each as an "island"
333
+ // in the report — the shape of the drill-down doesn't differ, only the id.
334
+ const islandSources: { name: string; url: string; priority: AnalyzeIsland["priority"]; deps: string[] }[] = [];
335
+
336
+ for (const [routeId, entry] of Object.entries(manifest.bundles ?? {})) {
337
+ islandSources.push({
338
+ name: routeId,
339
+ url: entry.js,
340
+ priority: entry.priority,
341
+ deps: entry.dependencies ?? [],
342
+ });
343
+ }
344
+ for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) {
345
+ // Avoid double-counting: if a per-island chunk shares its route id with
346
+ // a route-level bundle, we prefer the island entry (finer granularity).
347
+ const existing = islandSources.findIndex((s) => s.name === islandName);
348
+ if (existing !== -1) islandSources.splice(existing, 1);
349
+ islandSources.push({
350
+ name: islandName,
351
+ url: entry.js,
352
+ priority: entry.priority,
353
+ deps: [],
354
+ });
355
+ }
356
+
357
+ const islands: AnalyzeIsland[] = [];
358
+ for (const src of islandSources) {
359
+ const abs = urlToFsPath(rootDir, src.url);
360
+ if (!abs) continue;
361
+ const { raw, gz } = await measureFile(abs);
362
+ const modules = await readTopModules(abs, raw, gz);
363
+
364
+ // Shared-chunk attribution: every island implicitly depends on the
365
+ // `runtime` + `vendor` chunks the bundler emits as a contract; `router`
366
+ // is added when the app uses SPA navigation. We add the dep names here
367
+ // so `shared[].usedBy` stays in sync with the treemap links.
368
+ const implicitShared = ["runtime", "vendor"];
369
+ if (sharedById.has("router")) implicitShared.push("router");
370
+ const sharedDeps = [...new Set([...implicitShared, ...src.deps])].filter((id) =>
371
+ sharedById.has(id)
372
+ );
373
+ for (const depId of sharedDeps) {
374
+ const entry = sharedById.get(depId);
375
+ if (entry && !entry.usedBy.includes(src.name)) entry.usedBy.push(src.name);
376
+ }
377
+
378
+ islands.push({
379
+ name: src.name,
380
+ js: src.url,
381
+ totalRaw: raw,
382
+ totalGz: gz,
383
+ priority: src.priority,
384
+ shared: sharedDeps,
385
+ modules,
386
+ });
387
+ }
388
+
389
+ islands.sort((a, b) => b.totalRaw - a.totalRaw);
390
+ shared.sort((a, b) => b.size - a.size);
391
+
392
+ // ── Step 3: Summary ──────────────────────────────────────────────────────
393
+ const islandTotalRaw = islands.reduce((s, i) => s + i.totalRaw, 0);
394
+ const islandTotalGz = islands.reduce((s, i) => s + i.totalGz, 0);
395
+ const sharedTotalRaw = shared.reduce((s, c) => s + c.size, 0);
396
+ const sharedTotalGz = shared.reduce((s, c) => s + c.gz, 0);
397
+
398
+ // Dedupe savings — if every island had to inline each shared chunk it
399
+ // uses, the wire cost would be `sum_over_islands(sharedUsed). Subtracting
400
+ // the one-copy cost gives the "load-once" savings the manifest unlocks.
401
+ let dedupeSavings = 0;
402
+ for (const chunk of shared) {
403
+ if (chunk.usedBy.length > 1) {
404
+ dedupeSavings += chunk.size * (chunk.usedBy.length - 1);
405
+ }
406
+ }
407
+
408
+ let heaviestDep: AnalyzeSummary["heaviestDep"] = null;
409
+ for (const island of islands) {
410
+ for (const m of island.modules) {
411
+ if (!heaviestDep || m.size > heaviestDep.size) {
412
+ heaviestDep = { path: m.path, size: m.size };
413
+ }
414
+ }
415
+ }
416
+
417
+ const summary: AnalyzeSummary = {
418
+ totalRaw: islandTotalRaw + sharedTotalRaw,
419
+ totalGz: islandTotalGz + sharedTotalGz,
420
+ largestIsland: islands[0]
421
+ ? { name: islands[0].name, totalRaw: islands[0].totalRaw }
422
+ : null,
423
+ heaviestDep,
424
+ islandCount: islands.length,
425
+ sharedCount: shared.length,
426
+ dedupeSavings,
427
+ version: 1,
428
+ generatedAt: new Date().toISOString(),
429
+ };
430
+
431
+ return { islands, shared, summary };
432
+ }
433
+
434
+ // ============================================================================
435
+ // Serializers
436
+ // ============================================================================
437
+
438
+ /**
439
+ * Write `report.json` + `report.html` to `<rootDir>/.mandu/analyze/`.
440
+ *
441
+ * Returns the absolute paths of both files so the CLI can print them.
442
+ * Callers that want JSON only can skip the HTML step via `{ htmlPath: null }`.
443
+ *
444
+ * Phase 18.φ — `opts.budget` is an optional pre-computed budget report
445
+ * that, when present, renders a budget-bar section in the HTML output
446
+ * and is serialised alongside `report.json` as `report.budget`.
447
+ */
448
+ export async function writeAnalyzeReport(
449
+ rootDir: string,
450
+ report: AnalyzeReport,
451
+ opts: { html?: boolean; budget?: BudgetReport | null } = {}
452
+ ): Promise<{ jsonPath: string; htmlPath: string | null }> {
453
+ const outDir = path.join(rootDir, ".mandu", "analyze");
454
+ await fs.mkdir(outDir, { recursive: true });
455
+ const jsonPath = path.join(outDir, "report.json");
456
+ const jsonPayload = opts.budget ? { ...report, budget: opts.budget } : report;
457
+ await fs.writeFile(jsonPath, JSON.stringify(jsonPayload, null, 2), "utf8");
458
+
459
+ let htmlPath: string | null = null;
460
+ if (opts.html !== false) {
461
+ htmlPath = path.join(outDir, "report.html");
462
+ await fs.writeFile(htmlPath, renderAnalyzeHtml(report, opts.budget ?? null), "utf8");
463
+ }
464
+ return { jsonPath, htmlPath };
465
+ }
466
+
467
+ // ============================================================================
468
+ // HTML report
469
+ // ============================================================================
470
+
471
+ /**
472
+ * Render a self-contained single-file HTML report. No external CDN, no d3,
473
+ * no webpack-bundle-analyzer — just inline SVG + a hand-rolled squarify
474
+ * treemap. The result is ~12-30 KB for typical projects.
475
+ *
476
+ * Design notes:
477
+ * - Dark theme + monospace font so stack-trace-like paths stay readable.
478
+ * - Clicking an island rectangle drills into its module breakdown.
479
+ * - ESC / click-outside returns to the island view.
480
+ * - All SVG elements are generated server-side — the client script only
481
+ * toggles visibility. This keeps the report working even with JS
482
+ * disabled (you lose drill-down, but the island treemap still renders).
483
+ */
484
+ export function renderAnalyzeHtml(
485
+ report: AnalyzeReport,
486
+ budget: BudgetReport | null = null
487
+ ): string {
488
+ const { islands, shared, summary } = report;
489
+
490
+ // ── Island-level treemap ─────────────────────────────────────────────────
491
+ const VIEW_W = 960;
492
+ const VIEW_H = 480;
493
+ const islandRects = squarify(
494
+ islands.map((i) => ({ name: i.name, value: Math.max(i.totalRaw, 1), island: i })),
495
+ VIEW_W,
496
+ VIEW_H
497
+ );
498
+
499
+ const islandSvg = islandRects
500
+ .map((r, idx) => {
501
+ const color = ISLAND_PALETTE[idx % ISLAND_PALETTE.length];
502
+ const island = r.data.island;
503
+ const label = `${r.data.name}\n${fmtBytes(island.totalRaw)} / ${fmtBytes(island.totalGz)} gz`;
504
+ const canLabel = r.w > 60 && r.h > 28;
505
+ // XSS note: island names come from user route ids / island file
506
+ // basenames, so they are not fully trusted. We never embed the raw
507
+ // name in a JS string context — the drill target is carried on
508
+ // `data-drill` and a single delegated event listener reads it via
509
+ // `Element.dataset`, which does zero string parsing.
510
+ return `
511
+ <g class="island-cell" data-drill="${escAttr(r.data.name)}" tabindex="0" role="button" style="cursor:pointer">
512
+ <rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" fill="${color}" stroke="#0a0f14" stroke-width="1"/>
513
+ ${
514
+ canLabel
515
+ ? `<text x="${r.x + 8}" y="${r.y + 18}" fill="#fff" font-size="12" font-family="ui-monospace,Menlo,Consolas,monospace">${escText(r.data.name)}</text>
516
+ <text x="${r.x + 8}" y="${r.y + 34}" fill="#cbd5e1" font-size="10" font-family="ui-monospace,Menlo,Consolas,monospace">${fmtBytes(
517
+ island.totalRaw
518
+ )} / ${fmtBytes(island.totalGz)} gz</text>`
519
+ : ""
520
+ }
521
+ <title>${escText(label)}</title>
522
+ </g>`;
523
+ })
524
+ .join("");
525
+
526
+ // ── Per-island drill-down panels ─────────────────────────────────────────
527
+ //
528
+ // DOM id is `drill-<index>` (a safe numeric) rather than `drill-<name>`
529
+ // so the name never enters an id-attribute context. The name itself is
530
+ // still shown via the `data-drill` attribute matched by the client
531
+ // script, both values normalised by `escAttr` / `escText`.
532
+ const drillPanels = islands
533
+ .map((island, idx) => {
534
+ const safeId = `drill-${idx}`;
535
+ if (island.modules.length === 0) {
536
+ return `<section class="drill" id="${safeId}" data-drill="${escAttr(island.name)}" hidden>
537
+ <h3>${escText(island.name)}</h3>
538
+ <p class="muted">No sourcemap available for this bundle. Re-run with <code>mandu build --sourcemap --analyze</code> to see per-module breakdown.</p>
539
+ </section>`;
540
+ }
541
+ const maxSize = Math.max(...island.modules.map((m) => m.size), 1);
542
+ const rows = island.modules
543
+ .map((m) => {
544
+ const pct = (m.size / island.totalRaw) * 100;
545
+ const barW = (m.size / maxSize) * 100;
546
+ return `<tr>
547
+ <td class="mod-path" title="${escAttr(m.path)}">${escText(m.path)}</td>
548
+ <td class="num">${fmtBytes(m.size)}</td>
549
+ <td class="num muted">~${fmtBytes(m.gz)}</td>
550
+ <td class="num">${pct.toFixed(1)}%</td>
551
+ <td class="bar"><span style="width:${barW.toFixed(2)}%"></span></td>
552
+ </tr>`;
553
+ })
554
+ .join("");
555
+ return `<section class="drill" id="${safeId}" data-drill="${escAttr(island.name)}" hidden>
556
+ <h3>${escText(island.name)} <span class="muted">${fmtBytes(island.totalRaw)} raw · ${fmtBytes(island.totalGz)} gz · shared: ${island.shared.join(", ") || "—"}</span></h3>
557
+ <table class="modtable">
558
+ <thead><tr><th>module</th><th>size</th><th>~gz</th><th>%</th><th></th></tr></thead>
559
+ <tbody>${rows}</tbody>
560
+ </table>
561
+ </section>`;
562
+ })
563
+ .join("");
564
+
565
+ // ── Shared-chunks table ──────────────────────────────────────────────────
566
+ const sharedRows = shared
567
+ .map(
568
+ (c) => `<tr>
569
+ <td>${escText(c.id)}</td>
570
+ <td class="num">${fmtBytes(c.size)}</td>
571
+ <td class="num">${fmtBytes(c.gz)}</td>
572
+ <td>${c.usedBy.length}</td>
573
+ <td class="muted">${escText(c.usedBy.join(", ") || "—")}</td>
574
+ </tr>`
575
+ )
576
+ .join("");
577
+
578
+ // ── Summary cards ────────────────────────────────────────────────────────
579
+ const summaryCards = `
580
+ <div class="card"><div class="label">Total raw</div><div class="value">${fmtBytes(summary.totalRaw)}</div></div>
581
+ <div class="card"><div class="label">Total gzip</div><div class="value">${fmtBytes(summary.totalGz)}</div></div>
582
+ <div class="card"><div class="label">Islands</div><div class="value">${summary.islandCount}</div></div>
583
+ <div class="card"><div class="label">Shared chunks</div><div class="value">${summary.sharedCount}</div></div>
584
+ <div class="card"><div class="label">Largest island</div><div class="value">${
585
+ summary.largestIsland
586
+ ? `${escText(summary.largestIsland.name)} (${fmtBytes(summary.largestIsland.totalRaw)})`
587
+ : "—"
588
+ }</div></div>
589
+ <div class="card"><div class="label">Heaviest dep</div><div class="value">${
590
+ summary.heaviestDep
591
+ ? `${escText(summary.heaviestDep.path)} (${fmtBytes(summary.heaviestDep.size)})`
592
+ : "—"
593
+ }</div></div>
594
+ <div class="card"><div class="label">Dedupe savings</div><div class="value">${fmtBytes(summary.dedupeSavings)}</div></div>
595
+ `;
596
+
597
+ // ── Phase 18.φ — Budget bar section ──────────────────────────────────────
598
+ //
599
+ // Renders one horizontal bar per island when a budget was evaluated,
600
+ // coloured by `BudgetStatus`: green (within), yellow (within 10% of
601
+ // limit), red (exceeded). The bar width is proportional to
602
+ // `island.gz / gzLimit` (or `raw / rawLimit` if gzLimit is null).
603
+ // When every axis is unconstrained the bar is hidden with a muted "—"
604
+ // placeholder. Matches the "red/yellow/green" spec in Phase 18.φ.
605
+ const budgetSection = budget
606
+ ? renderBudgetSection(budget)
607
+ : "";
608
+
609
+ return `<!doctype html>
610
+ <html lang="en">
611
+ <head>
612
+ <meta charset="utf-8">
613
+ <meta name="viewport" content="width=device-width,initial-scale=1">
614
+ <title>Mandu Bundle Analyzer</title>
615
+ <style>
616
+ :root { color-scheme: dark; }
617
+ html, body { margin: 0; padding: 0; background: #0a0f14; color: #e5e7eb; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
618
+ header { padding: 16px 20px; border-bottom: 1px solid #1f2937; display: flex; align-items: baseline; gap: 12px; }
619
+ header h1 { font-size: 16px; margin: 0; font-weight: 600; color: #f3f4f6; }
620
+ header .meta { color: #64748b; font-size: 11px; }
621
+ main { padding: 20px; max-width: 1080px; margin: 0 auto; }
622
+ h2 { font-size: 14px; color: #9ca3af; font-weight: 600; margin: 24px 0 8px; text-transform: uppercase; letter-spacing: 0.08em; }
623
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }
624
+ .card { background: #111827; border: 1px solid #1f2937; border-radius: 4px; padding: 10px 12px; }
625
+ .card .label { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
626
+ .card .value { color: #f3f4f6; font-size: 14px; margin-top: 4px; word-break: break-all; }
627
+ svg.treemap { background: #0f172a; border: 1px solid #1f2937; border-radius: 4px; display: block; width: 100%; height: auto; }
628
+ .island-cell:hover rect { stroke: #38bdf8; stroke-width: 2; }
629
+ .island-cell:focus rect { stroke: #38bdf8; stroke-width: 2; outline: none; }
630
+ table { width: 100%; border-collapse: collapse; }
631
+ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #1f2937; font-size: 12px; }
632
+ th { color: #64748b; font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
633
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
634
+ td.mod-path { max-width: 480px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
635
+ td.bar { width: 120px; }
636
+ td.bar span { display: block; height: 8px; background: linear-gradient(90deg, #38bdf8, #0ea5e9); border-radius: 2px; }
637
+ .muted { color: #64748b; }
638
+ .modtable tr:hover td { background: #0f172a; }
639
+ section.drill { margin-top: 16px; background: #0f172a; border: 1px solid #1f2937; border-radius: 4px; padding: 14px; }
640
+ section.drill h3 { margin: 0 0 10px; font-size: 13px; color: #e5e7eb; font-weight: 600; }
641
+ code { background: #1f2937; padding: 1px 4px; border-radius: 2px; color: #93c5fd; }
642
+ button.close { background: #1f2937; color: #e5e7eb; border: 1px solid #374151; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-family: inherit; font-size: 11px; }
643
+ button.close:hover { background: #374151; }
644
+ .drill-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
645
+ /* Phase 18.φ — budget bars */
646
+ .budget-row { display: grid; grid-template-columns: 180px 1fr 160px; gap: 10px; align-items: center; padding: 4px 0; border-bottom: 1px solid #1f2937; }
647
+ .budget-row:last-child { border-bottom: none; }
648
+ .budget-name { font-size: 12px; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
649
+ .budget-meta { font-size: 11px; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums; }
650
+ .budget-bar-track { position: relative; height: 10px; background: #0f172a; border: 1px solid #1f2937; border-radius: 2px; overflow: hidden; }
651
+ .budget-bar-fill { height: 100%; border-radius: 2px; }
652
+ .budget-bar-fill.within { background: linear-gradient(90deg, #16a34a, #22c55e); }
653
+ .budget-bar-fill.within10 { background: linear-gradient(90deg, #ca8a04, #eab308); }
654
+ .budget-bar-fill.exceeded { background: linear-gradient(90deg, #b91c1c, #ef4444); }
655
+ .budget-bar-fill.unbounded { background: repeating-linear-gradient(45deg, #1f2937, #1f2937 4px, #0f172a 4px, #0f172a 8px); }
656
+ .budget-legend { display: inline-flex; gap: 14px; font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
657
+ .budget-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
658
+ .budget-legend .lg-within::before { background: #22c55e; }
659
+ .budget-legend .lg-within10::before { background: #eab308; }
660
+ .budget-legend .lg-exceeded::before { background: #ef4444; }
661
+ .budget-mode { display: inline-block; font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 2px; border: 1px solid #374151; color: #cbd5e1; margin-left: 8px; letter-spacing: 0.04em; }
662
+ .budget-mode.error { background: #7f1d1d; border-color: #991b1b; color: #fecaca; }
663
+ .budget-mode.warning { background: #713f12; border-color: #854d0e; color: #fde68a; }
664
+ </style>
665
+ </head>
666
+ <body>
667
+ <header>
668
+ <h1>Mandu Bundle Analyzer</h1>
669
+ <span class="meta">generated ${escText(summary.generatedAt)} · schema v${summary.version}</span>
670
+ </header>
671
+ <main>
672
+ <h2>Summary</h2>
673
+ <div class="cards">${summaryCards}</div>
674
+
675
+ ${budgetSection}
676
+
677
+ <h2>Islands (click to drill in)</h2>
678
+ <svg class="treemap" viewBox="0 0 ${VIEW_W} ${VIEW_H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Island bundle treemap">
679
+ ${islandSvg || `<text x="20" y="30" fill="#64748b">No islands to display.</text>`}
680
+ </svg>
681
+
682
+ <div id="drill-host">${drillPanels}</div>
683
+
684
+ <h2>Shared chunks</h2>
685
+ <table>
686
+ <thead><tr><th>id</th><th class="num">size</th><th class="num">gzip</th><th>used by #</th><th>islands</th></tr></thead>
687
+ <tbody>${sharedRows || `<tr><td colspan="5" class="muted">(no shared chunks — pure-SSR project?)</td></tr>`}</tbody>
688
+ </table>
689
+
690
+ <h2>Islands</h2>
691
+ <table>
692
+ <thead><tr><th>name</th><th class="num">raw</th><th class="num">gzip</th><th>priority</th><th>shared</th><th>modules</th></tr></thead>
693
+ <tbody>
694
+ ${islands
695
+ .map(
696
+ (i) => `<tr>
697
+ <td><a href="#" data-drill="${escAttr(i.name)}" class="drill-link">${escText(i.name)}</a></td>
698
+ <td class="num">${fmtBytes(i.totalRaw)}</td>
699
+ <td class="num">${fmtBytes(i.totalGz)}</td>
700
+ <td>${escText(i.priority)}</td>
701
+ <td class="muted">${escText(i.shared.join(", ") || "—")}</td>
702
+ <td class="num">${i.modules.length}</td>
703
+ </tr>`
704
+ )
705
+ .join("")}
706
+ </tbody>
707
+ </table>
708
+ </main>
709
+ <script>
710
+ (function () {
711
+ var current = null;
712
+ function drillTo(name) {
713
+ if (!name) return;
714
+ // Find the section by matching data-drill; IDs use a numeric index
715
+ // so we never build a selector from potentially unsafe user text.
716
+ var sections = document.querySelectorAll("section.drill");
717
+ var next = null;
718
+ for (var i = 0; i < sections.length; i++) {
719
+ if (sections[i].getAttribute("data-drill") === name) { next = sections[i]; break; }
720
+ }
721
+ if (!next) return;
722
+ if (current && current !== next) current.hidden = true;
723
+ next.hidden = false;
724
+ current = next;
725
+ next.scrollIntoView({ behavior: "smooth", block: "start" });
726
+ }
727
+ document.addEventListener("click", function (e) {
728
+ var t = e.target;
729
+ while (t && t.nodeType === 1) {
730
+ var d = t.getAttribute && t.getAttribute("data-drill");
731
+ if (d) { e.preventDefault(); drillTo(d); return; }
732
+ t = t.parentNode;
733
+ }
734
+ });
735
+ document.addEventListener("keydown", function (e) {
736
+ if (e.key === "Escape" && current) { current.hidden = true; current = null; }
737
+ });
738
+ })();
739
+ </script>
740
+ </body>
741
+ </html>`;
742
+ }
743
+
744
+ // ============================================================================
745
+ // Helpers — formatting + squarify
746
+ // ============================================================================
747
+
748
+ /**
749
+ * Phase 18.φ — render the budget-bar block. Colour-codes each island
750
+ * by {@link BudgetReport.BudgetStatus} and the project-wide total (when
751
+ * present). Islands without any applicable limit render a diagonal-
752
+ * hatched "unbounded" bar so the user sees the row but understands
753
+ * nothing is enforced. Self-contained: no JS, no external assets.
754
+ */
755
+ function renderBudgetSection(budget: BudgetReport): string {
756
+ const modeClass = budget.mode === "error" ? "error" : "warning";
757
+ const rows = budget.islands
758
+ .map((i) => renderBudgetRow(i.name, i.raw, i.gz, i.rawLimit, i.gzLimit, i.status))
759
+ .join("");
760
+ const totalRow = budget.total
761
+ ? renderBudgetRow(
762
+ "<project total>",
763
+ budget.total.raw,
764
+ budget.total.gz,
765
+ budget.total.rawLimit,
766
+ budget.total.gzLimit,
767
+ budget.total.status
768
+ )
769
+ : "";
770
+ const exceedHeadline = budget.hasExceeded
771
+ ? ` · <span style="color:#fca5a5">${budget.exceededCount} over limit</span>`
772
+ : "";
773
+ return `
774
+ <h2>Bundle budget <span class="budget-mode ${modeClass}">${escText(budget.mode)}</span></h2>
775
+ <div class="budget-legend">
776
+ <span class="lg-within">within</span>
777
+ <span class="lg-within10">approaching (≥90%)</span>
778
+ <span class="lg-exceeded">exceeded</span>
779
+ </div>
780
+ <p class="muted" style="margin:0 0 10px">
781
+ ${budget.withinCount}/${budget.islandCount} islands within limits${exceedHeadline}
782
+ </p>
783
+ <div class="card" style="padding:12px 14px">
784
+ ${rows}
785
+ ${totalRow}
786
+ </div>`;
787
+ }
788
+
789
+ function renderBudgetRow(
790
+ name: string,
791
+ raw: number,
792
+ gz: number,
793
+ rawLimit: number | null,
794
+ gzLimit: number | null,
795
+ status: "within" | "within10" | "exceeded"
796
+ ): string {
797
+ // Prefer gz-axis progress bar when a gz limit exists (the 90%-of-the-
798
+ // time-useful axis); fall back to raw when only raw is constrained.
799
+ let pct = 0;
800
+ let barClass: string = status;
801
+ let meta: string;
802
+ if (gzLimit !== null) {
803
+ pct = Math.min(100, Math.max(0, (gz / Math.max(gzLimit, 1)) * 100));
804
+ meta = `${fmtBytes(gz)} / ${fmtBytes(gzLimit)} gz`;
805
+ } else if (rawLimit !== null) {
806
+ pct = Math.min(100, Math.max(0, (raw / Math.max(rawLimit, 1)) * 100));
807
+ meta = `${fmtBytes(raw)} / ${fmtBytes(rawLimit)} raw`;
808
+ } else {
809
+ barClass = "unbounded";
810
+ pct = 100;
811
+ meta = `${fmtBytes(gz)} gz · no limit`;
812
+ }
813
+ return `
814
+ <div class="budget-row">
815
+ <div class="budget-name" title="${escAttr(name)}">${escText(name)}</div>
816
+ <div class="budget-bar-track"><div class="budget-bar-fill ${barClass}" style="width:${pct.toFixed(1)}%"></div></div>
817
+ <div class="budget-meta">${meta}</div>
818
+ </div>`;
819
+ }
820
+
821
+ /** Human-readable byte formatter. Matches the style used by `printBundleStats`. */
822
+ export function fmtBytes(n: number): string {
823
+ if (!Number.isFinite(n) || n <= 0) return "0 B";
824
+ if (n < 1024) return `${n} B`;
825
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
826
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
827
+ }
828
+
829
+ function escText(s: string): string {
830
+ return s
831
+ .replace(/&/g, "&amp;")
832
+ .replace(/</g, "&lt;")
833
+ .replace(/>/g, "&gt;");
834
+ }
835
+
836
+ function escAttr(s: string): string {
837
+ return escText(s).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
838
+ }
839
+
840
+ /** Distinct dark-theme colour ramp, no dependency on d3-scale. */
841
+ const ISLAND_PALETTE = [
842
+ "#1e3a8a", "#155e75", "#166534", "#854d0e", "#7c2d12",
843
+ "#581c87", "#831843", "#0f766e", "#3730a3", "#92400e",
844
+ "#064e3b", "#6b21a8", "#134e4a", "#7f1d1d", "#1e40af",
845
+ ];
846
+
847
+ interface SquarifyInput<T> {
848
+ name: string;
849
+ value: number;
850
+ island: T;
851
+ }
852
+ interface SquarifyRect<T> {
853
+ x: number;
854
+ y: number;
855
+ w: number;
856
+ h: number;
857
+ data: SquarifyInput<T>;
858
+ }
859
+
860
+ /**
861
+ * Minimal squarify treemap layout.
862
+ *
863
+ * Classic Bruls-Huijing-van-Wijk algorithm — pack rectangles into a
864
+ * bounding box to minimise worst-case aspect ratio. Implemented in-repo so
865
+ * the HTML report has zero runtime deps. ~60 LOC.
866
+ *
867
+ * Input is pre-sorted descending by `value`. We maintain a current "row"
868
+ * (strip) and keep adding rectangles until the worst aspect ratio would
869
+ * increase if we added the next one; at that point we lay out the row
870
+ * and start a new one on the remaining area.
871
+ */
872
+ function squarify<T>(
873
+ input: SquarifyInput<T>[],
874
+ width: number,
875
+ height: number
876
+ ): SquarifyRect<T>[] {
877
+ const sorted = [...input].sort((a, b) => b.value - a.value);
878
+ const total = sorted.reduce((s, n) => s + n.value, 0);
879
+ if (total <= 0 || sorted.length === 0) return [];
880
+
881
+ // Scale values so the sum equals width * height.
882
+ const scale = (width * height) / total;
883
+ const scaled = sorted.map((n) => ({ ...n, area: n.value * scale }));
884
+
885
+ const result: SquarifyRect<T>[] = [];
886
+
887
+ function worst(row: number[], side: number): number {
888
+ if (row.length === 0) return Infinity;
889
+ const sum = row.reduce((s, v) => s + v, 0);
890
+ const rowMax = Math.max(...row);
891
+ const rowMin = Math.min(...row);
892
+ const s2 = side * side;
893
+ const sum2 = sum * sum;
894
+ return Math.max((s2 * rowMax) / sum2, sum2 / (s2 * rowMin));
895
+ }
896
+
897
+ let x = 0;
898
+ let y = 0;
899
+ let w = width;
900
+ let h = height;
901
+ let i = 0;
902
+
903
+ while (i < scaled.length) {
904
+ const row: number[] = [];
905
+ const rowData: typeof scaled = [];
906
+ const side = Math.min(w, h);
907
+ // Build row.
908
+ while (i < scaled.length) {
909
+ const next = scaled[i].area;
910
+ const candidate = [...row, next];
911
+ if (row.length === 0 || worst(candidate, side) <= worst(row, side)) {
912
+ row.push(next);
913
+ rowData.push(scaled[i]);
914
+ i++;
915
+ } else {
916
+ break;
917
+ }
918
+ }
919
+ // Lay out row.
920
+ const rowSum = row.reduce((s, v) => s + v, 0);
921
+ const rowThickness = rowSum / side;
922
+ if (w <= h) {
923
+ // Horizontal strip along the top.
924
+ let cx = x;
925
+ for (let r = 0; r < row.length; r++) {
926
+ const rw = row[r] / rowThickness;
927
+ result.push({
928
+ x: cx,
929
+ y,
930
+ w: rw,
931
+ h: rowThickness,
932
+ data: rowData[r],
933
+ });
934
+ cx += rw;
935
+ }
936
+ y += rowThickness;
937
+ h -= rowThickness;
938
+ } else {
939
+ // Vertical strip along the left.
940
+ let cy = y;
941
+ for (let r = 0; r < row.length; r++) {
942
+ const rh = row[r] / rowThickness;
943
+ result.push({
944
+ x,
945
+ y: cy,
946
+ w: rowThickness,
947
+ h: rh,
948
+ data: rowData[r],
949
+ });
950
+ cy += rh;
951
+ }
952
+ x += rowThickness;
953
+ w -= rowThickness;
954
+ }
955
+ }
956
+
957
+ return result;
958
+ }