@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/bundler/analyzer.ts
CHANGED
|
@@ -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, "&")
|
|
832
|
-
.replace(/</g, "<")
|
|
833
|
-
.replace(/>/g, ">");
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function escAttr(s: string): string {
|
|
837
|
-
return escText(s).replace(/"/g, """).replace(/'/g, "'");
|
|
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, "&")
|
|
832
|
+
.replace(/</g, "<")
|
|
833
|
+
.replace(/>/g, ">");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function escAttr(s: string): string {
|
|
837
|
+
return escText(s).replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|