@lakindu_perera/toren 1.0.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.
@@ -0,0 +1,913 @@
1
+ /**
2
+ * @fileoverview Toren — HTML Renderer
3
+ *
4
+ * Consumes a {@link ScanResult} and writes a complete, self-contained HTML5
5
+ * report to stdout. The output is suitable for opening directly in a browser
6
+ * or sharing as a static file:
7
+ *
8
+ * toren --format html > report.html
9
+ *
10
+ * Design contract (mirrors all other renderers):
11
+ * - Accepts a ScanResult and an optional options object.
12
+ * - Never scans files or modifies the data it receives.
13
+ * - Produces a single HTML document with all CSS inlined — no CDN, no
14
+ * external assets, no runtime JavaScript required.
15
+ * - All output goes to stdout so users can redirect freely.
16
+ *
17
+ * Sections (in order):
18
+ * 1. Header — project title, path badge, type badge
19
+ * 2. Summary cards — project type, files, folders, scan duration
20
+ * 3. Entry Points — list of detected entry files
21
+ * 4. Folder Structure — tree view inside a dark <pre><code> block
22
+ * 5. Statistics — metric table
23
+ * 6. Scan Info — provenance metadata
24
+ *
25
+ * @module renderers/html-renderer
26
+ */
27
+
28
+ import path from 'node:path';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Tree builder (plain-text, HTML-safe — same algorithm as markdown-renderer)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Build an in-memory nested tree from a flat list of relative file paths.
36
+ *
37
+ * @param {string[]} flatFiles - Relative file paths produced by scan()
38
+ * @returns {{ type: string, children: Record<string, object> }}
39
+ */
40
+ function buildInternalTree(flatFiles) {
41
+ const root = { type: 'directory', children: {} };
42
+
43
+ for (const filePath of flatFiles) {
44
+ const parts = filePath.split(/[/\\]/).filter(Boolean);
45
+ let node = root;
46
+
47
+ for (let i = 0; i < parts.length; i++) {
48
+ const part = parts[i];
49
+ const isLeaf = i === parts.length - 1;
50
+
51
+ if (!node.children[part]) {
52
+ node.children[part] = isLeaf
53
+ ? { type: 'file', name: part }
54
+ : { type: 'directory', name: part, children: {} };
55
+ }
56
+ node = node.children[part];
57
+ }
58
+ }
59
+
60
+ return root;
61
+ }
62
+
63
+ /**
64
+ * Recursively serialise a tree node into classic tree-connector lines.
65
+ *
66
+ * @param {object} node
67
+ * @param {string} prefix
68
+ * @param {boolean} isLast
69
+ * @param {string[]} lines
70
+ * @param {number} depth
71
+ * @param {number} maxDepth
72
+ */
73
+ function serializeNode(node, prefix, isLast, lines, depth = 0, maxDepth = 5) {
74
+ if (depth >= maxDepth) return;
75
+
76
+ const connector = isLast ? '└── ' : '├── ';
77
+ const childPad = isLast ? ' ' : '│ ';
78
+ const label = node.type === 'directory' ? `${node.name}/` : node.name;
79
+
80
+ lines.push(`${prefix}${connector}${label}`);
81
+
82
+ if (node.type === 'directory') {
83
+ const children = Object.values(node.children || {}).sort((a, b) => {
84
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
85
+ return a.name.localeCompare(b.name);
86
+ });
87
+
88
+ if (depth === maxDepth - 1 && children.length > 0) {
89
+ lines.push(`${prefix}${childPad}└── ...`);
90
+ return;
91
+ }
92
+
93
+ for (let i = 0; i < children.length; i++) {
94
+ serializeNode(
95
+ children[i],
96
+ prefix + childPad,
97
+ i === children.length - 1,
98
+ lines,
99
+ depth + 1,
100
+ maxDepth,
101
+ );
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Convert a flat file list into a plain-text tree string.
108
+ *
109
+ * @param {string[]} flatFiles
110
+ * @param {string} rootName
111
+ * @returns {string}
112
+ */
113
+ function buildTreeString(flatFiles, rootName) {
114
+ if (flatFiles.length === 0) return 'No files scanned.';
115
+
116
+ const root = buildInternalTree(flatFiles);
117
+ const lines = [`${rootName}/`];
118
+ const children = Object.values(root.children).sort((a, b) => {
119
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
120
+ return a.name.localeCompare(b.name);
121
+ });
122
+
123
+ for (let i = 0; i < children.length; i++) {
124
+ serializeNode(children[i], '', i === children.length - 1, lines);
125
+ }
126
+
127
+ return lines.join('\n');
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Utility helpers
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Escape a value for safe insertion into HTML text nodes and attributes.
136
+ *
137
+ * @param {unknown} value
138
+ * @returns {string}
139
+ */
140
+ function esc(value) {
141
+ return String(value ?? '')
142
+ .replace(/&/g, '&amp;')
143
+ .replace(/</g, '&lt;')
144
+ .replace(/>/g, '&gt;')
145
+ .replace(/"/g, '&quot;')
146
+ .replace(/'/g, '&#39;');
147
+ }
148
+
149
+ /**
150
+ * Format a scan duration in milliseconds to a human-readable string.
151
+ *
152
+ * @param {number} ms
153
+ * @returns {string}
154
+ */
155
+ function formatDuration(ms) {
156
+ if (ms < 1) return '&lt; 1 ms';
157
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
158
+ return `${Math.round(ms)} ms`;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // CSS (inlined — zero external dependencies)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Build the complete <style> block for the report.
167
+ * All colours are driven by CSS custom properties so they are easy to theme.
168
+ *
169
+ * @returns {string}
170
+ */
171
+ function buildStyles() {
172
+ return `<style>
173
+ /* ── Reset ──────────────────────────────────────────────────── */
174
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
175
+
176
+ /* ── Design tokens ──────────────────────────────────────────── */
177
+ :root {
178
+ --bg: #f1f5f9;
179
+ --surface: #ffffff;
180
+ --border: #e2e8f0;
181
+ --text: #0f172a;
182
+ --muted: #64748b;
183
+ --accent: #4f46e5;
184
+ --accent-light: #eef2ff;
185
+ --accent-2: #818cf8;
186
+ --success: #10b981;
187
+ --success-bg: #ecfdf5;
188
+ --code-bg: #0f172a;
189
+ --code-text: #e2e8f0;
190
+ --code-comment: #64748b;
191
+ --radius-sm: 6px;
192
+ --radius: 12px;
193
+ --radius-lg: 16px;
194
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.06);
195
+ --shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.05);
196
+ --shadow-lg: 0 4px 6px rgba(0,0,0,.07), 0 10px 30px rgba(0,0,0,.08);
197
+ }
198
+
199
+ /* ── Base ────────────────────────────────────────────────────── */
200
+ html { scroll-behavior: smooth; }
201
+
202
+ body {
203
+ font-family: system-ui, -apple-system, BlinkMacSystemFont,
204
+ 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
205
+ background: var(--bg);
206
+ color: var(--text);
207
+ line-height: 1.65;
208
+ padding: 0 1.25rem 4rem;
209
+ -webkit-font-smoothing: antialiased;
210
+ }
211
+
212
+ /* ── Page wrapper ────────────────────────────────────────────── */
213
+ .page {
214
+ max-width: 980px;
215
+ margin: 0 auto;
216
+ }
217
+
218
+ /* ── Header ──────────────────────────────────────────────────── */
219
+ .header {
220
+ background: linear-gradient(135deg, #1e1b4b 0%, #312e81 55%, #4338ca 100%);
221
+ color: #fff;
222
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
223
+ padding: 2.75rem 2.5rem 2.25rem;
224
+ margin-bottom: 2rem;
225
+ position: relative;
226
+ overflow: hidden;
227
+ }
228
+
229
+ /* Subtle grid texture */
230
+ .header::before {
231
+ content: '';
232
+ position: absolute;
233
+ inset: 0;
234
+ background-image:
235
+ linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
236
+ linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
237
+ background-size: 32px 32px;
238
+ pointer-events: none;
239
+ }
240
+
241
+ .header-top {
242
+ position: relative;
243
+ display: flex;
244
+ align-items: flex-start;
245
+ justify-content: space-between;
246
+ gap: 1.25rem;
247
+ flex-wrap: wrap;
248
+ }
249
+
250
+ .header-brand {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: .65rem;
254
+ font-size: .78rem;
255
+ font-weight: 600;
256
+ letter-spacing: .08em;
257
+ text-transform: uppercase;
258
+ color: rgba(255,255,255,.5);
259
+ margin-bottom: .65rem;
260
+ }
261
+
262
+ .header-logo {
263
+ width: 20px;
264
+ height: 20px;
265
+ background: var(--accent-2);
266
+ border-radius: 5px;
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: center;
270
+ font-size: .7rem;
271
+ }
272
+
273
+ .header h1 {
274
+ font-size: 1.9rem;
275
+ font-weight: 800;
276
+ letter-spacing: -.03em;
277
+ line-height: 1.15;
278
+ color: #fff;
279
+ }
280
+
281
+ .header-subtitle {
282
+ margin-top: .45rem;
283
+ color: rgba(255,255,255,.6);
284
+ font-size: .95rem;
285
+ font-weight: 400;
286
+ }
287
+
288
+ /* Project-type badge (top-right) */
289
+ .type-badge {
290
+ position: relative;
291
+ display: inline-flex;
292
+ align-items: center;
293
+ gap: .5rem;
294
+ background: rgba(255,255,255,.12);
295
+ border: 1px solid rgba(255,255,255,.2);
296
+ backdrop-filter: blur(6px);
297
+ -webkit-backdrop-filter: blur(6px);
298
+ color: #fff;
299
+ font-size: .82rem;
300
+ font-weight: 600;
301
+ padding: .45rem 1rem;
302
+ border-radius: 99px;
303
+ white-space: nowrap;
304
+ flex-shrink: 0;
305
+ }
306
+
307
+ .type-badge-dot {
308
+ width: 7px;
309
+ height: 7px;
310
+ border-radius: 50%;
311
+ background: var(--accent-2);
312
+ animation: pulse 2s ease-in-out infinite;
313
+ }
314
+
315
+ @keyframes pulse {
316
+ 0%, 100% { opacity: 1; }
317
+ 50% { opacity: .4; }
318
+ }
319
+
320
+ /* Scan path pill */
321
+ .path-pill {
322
+ position: relative;
323
+ display: inline-flex;
324
+ align-items: center;
325
+ gap: .55rem;
326
+ margin-top: 1.5rem;
327
+ background: rgba(0,0,0,.3);
328
+ border: 1px solid rgba(255,255,255,.1);
329
+ border-radius: var(--radius-sm);
330
+ padding: .45rem 1rem;
331
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', Consolas, monospace;
332
+ font-size: .83rem;
333
+ color: rgba(255,255,255,.75);
334
+ letter-spacing: .01em;
335
+ }
336
+
337
+ .path-pill svg { flex-shrink: 0; opacity: .6; }
338
+
339
+ /* ── Summary Cards ───────────────────────────────────────────── */
340
+ .cards {
341
+ display: grid;
342
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
343
+ gap: 1rem;
344
+ margin-bottom: 1.5rem;
345
+ }
346
+
347
+ .card {
348
+ background: var(--surface);
349
+ border: 1px solid var(--border);
350
+ border-radius: var(--radius);
351
+ padding: 1.4rem 1.6rem;
352
+ box-shadow: var(--shadow);
353
+ transition: box-shadow .2s ease, transform .2s ease;
354
+ }
355
+
356
+ .card:hover {
357
+ box-shadow: var(--shadow-lg);
358
+ transform: translateY(-1px);
359
+ }
360
+
361
+ .card-label {
362
+ display: flex;
363
+ align-items: center;
364
+ gap: .45rem;
365
+ font-size: .72rem;
366
+ font-weight: 700;
367
+ text-transform: uppercase;
368
+ letter-spacing: .07em;
369
+ color: var(--muted);
370
+ margin-bottom: .65rem;
371
+ }
372
+
373
+ .card-icon {
374
+ width: 22px;
375
+ height: 22px;
376
+ border-radius: 5px;
377
+ background: var(--accent-light);
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ font-size: .65rem;
382
+ color: var(--accent);
383
+ flex-shrink: 0;
384
+ }
385
+
386
+ .card-value {
387
+ font-size: 2rem;
388
+ font-weight: 800;
389
+ line-height: 1;
390
+ letter-spacing: -.04em;
391
+ color: var(--text);
392
+ font-variant-numeric: tabular-nums;
393
+ }
394
+
395
+ .card-value.is-text {
396
+ font-size: 1.2rem;
397
+ font-weight: 700;
398
+ letter-spacing: -.02em;
399
+ color: var(--accent);
400
+ }
401
+
402
+ .card-sub {
403
+ font-size: .78rem;
404
+ color: var(--muted);
405
+ margin-top: .35rem;
406
+ font-weight: 500;
407
+ }
408
+
409
+ /* ── Sections ────────────────────────────────────────────────── */
410
+ .section {
411
+ background: var(--surface);
412
+ border: 1px solid var(--border);
413
+ border-radius: var(--radius);
414
+ box-shadow: var(--shadow);
415
+ margin-bottom: 1.5rem;
416
+ overflow: hidden;
417
+ }
418
+
419
+ .section-header {
420
+ display: flex;
421
+ align-items: center;
422
+ gap: .75rem;
423
+ padding: 1rem 1.5rem;
424
+ border-bottom: 1px solid var(--border);
425
+ background: #fafafa;
426
+ }
427
+
428
+ .section-icon {
429
+ width: 30px;
430
+ height: 30px;
431
+ border-radius: var(--radius-sm);
432
+ background: var(--accent-light);
433
+ display: flex;
434
+ align-items: center;
435
+ justify-content: center;
436
+ font-size: .9rem;
437
+ flex-shrink: 0;
438
+ }
439
+
440
+ .section-title {
441
+ font-size: .95rem;
442
+ font-weight: 700;
443
+ color: var(--text);
444
+ letter-spacing: -.01em;
445
+ }
446
+
447
+ .section-count {
448
+ margin-left: auto;
449
+ background: var(--accent-light);
450
+ color: var(--accent);
451
+ font-size: .72rem;
452
+ font-weight: 700;
453
+ padding: .2rem .55rem;
454
+ border-radius: 99px;
455
+ letter-spacing: .03em;
456
+ }
457
+
458
+ .section-body { padding: 1.5rem; }
459
+
460
+ /* ── Entry Points ────────────────────────────────────────────── */
461
+ .entry-list {
462
+ list-style: none;
463
+ display: flex;
464
+ flex-direction: column;
465
+ gap: .5rem;
466
+ }
467
+
468
+ .entry-item {
469
+ display: flex;
470
+ align-items: center;
471
+ gap: .75rem;
472
+ padding: .6rem .9rem;
473
+ background: var(--bg);
474
+ border: 1px solid var(--border);
475
+ border-radius: var(--radius-sm);
476
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', Consolas, monospace;
477
+ font-size: .85rem;
478
+ color: var(--text);
479
+ transition: border-color .15s ease, background .15s ease;
480
+ }
481
+
482
+ .entry-item:hover {
483
+ border-color: var(--accent-2);
484
+ background: var(--accent-light);
485
+ }
486
+
487
+ .entry-dot {
488
+ width: 8px;
489
+ height: 8px;
490
+ border-radius: 50%;
491
+ background: var(--success);
492
+ flex-shrink: 0;
493
+ box-shadow: 0 0 0 3px var(--success-bg);
494
+ }
495
+
496
+ .empty-msg {
497
+ color: var(--muted);
498
+ font-style: italic;
499
+ font-size: .9rem;
500
+ padding: .25rem 0;
501
+ }
502
+
503
+ /* ── Tree view ───────────────────────────────────────────────── */
504
+ .tree-wrap {
505
+ background: var(--code-bg);
506
+ border-radius: var(--radius-sm);
507
+ overflow: auto;
508
+ max-height: 520px;
509
+ }
510
+
511
+ .tree-wrap pre {
512
+ padding: 1.4rem 1.6rem;
513
+ margin: 0;
514
+ overflow: visible;
515
+ }
516
+
517
+ .tree-wrap code {
518
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', Consolas, monospace;
519
+ font-size: .82rem;
520
+ line-height: 1.8;
521
+ color: var(--code-text);
522
+ white-space: pre;
523
+ display: block;
524
+ }
525
+
526
+ /* ── Stats / Info Table ──────────────────────────────────────── */
527
+ .data-table {
528
+ width: 100%;
529
+ border-collapse: collapse;
530
+ font-size: .9rem;
531
+ }
532
+
533
+ .data-table th,
534
+ .data-table td {
535
+ padding: .8rem 1rem;
536
+ text-align: left;
537
+ border-bottom: 1px solid var(--border);
538
+ }
539
+
540
+ .data-table th {
541
+ font-size: .72rem;
542
+ font-weight: 700;
543
+ text-transform: uppercase;
544
+ letter-spacing: .06em;
545
+ color: var(--muted);
546
+ background: #fafafa;
547
+ }
548
+
549
+ .data-table td { color: var(--text); }
550
+
551
+ .data-table td.val {
552
+ text-align: right;
553
+ font-weight: 700;
554
+ color: var(--accent);
555
+ font-variant-numeric: tabular-nums;
556
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', Consolas, monospace;
557
+ font-size: .88rem;
558
+ }
559
+
560
+ .data-table td.val-plain {
561
+ text-align: right;
562
+ font-weight: 500;
563
+ color: var(--muted);
564
+ }
565
+
566
+ .data-table tr:last-child td { border-bottom: none; }
567
+
568
+ /* ── Footer ──────────────────────────────────────────────────── */
569
+ .footer {
570
+ text-align: center;
571
+ padding: 2rem 1rem 0;
572
+ font-size: .8rem;
573
+ color: var(--muted);
574
+ }
575
+
576
+ .footer-brand {
577
+ display: inline-flex;
578
+ align-items: center;
579
+ gap: .4rem;
580
+ font-weight: 600;
581
+ color: var(--accent);
582
+ }
583
+
584
+ /* ── Responsive ──────────────────────────────────────────────── */
585
+ @media (max-width: 640px) {
586
+ body { padding: 0 .75rem 3rem; }
587
+ .header { padding: 1.75rem 1.25rem 1.5rem; border-radius: 0 0 var(--radius) var(--radius); }
588
+ .header h1 { font-size: 1.45rem; }
589
+ .section-body { padding: 1.1rem; }
590
+ .cards { grid-template-columns: repeat(2, 1fr); }
591
+ .card { padding: 1.1rem 1.2rem; }
592
+ .card-value { font-size: 1.6rem; }
593
+ }
594
+
595
+ /* ── Print ───────────────────────────────────────────────────── */
596
+ @media print {
597
+ body { background: white; padding: 0; font-size: 11pt; }
598
+ .header {
599
+ -webkit-print-color-adjust: exact;
600
+ print-color-adjust: exact;
601
+ border-radius: 0;
602
+ }
603
+ .card, .section {
604
+ box-shadow: none;
605
+ border: 1px solid #ccc;
606
+ break-inside: avoid;
607
+ }
608
+ .tree-wrap { max-height: none; }
609
+ .card:hover, .entry-item:hover { transform: none; }
610
+ }
611
+ </style>`;
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // SVG icon helpers (zero external dependency)
616
+ // ---------------------------------------------------------------------------
617
+
618
+ /** @returns {string} */
619
+ const icon = {
620
+ code: () => `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
621
+ folder: () => `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
622
+ file: () => `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
623
+ clock: () => `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
624
+ door: () => `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H3a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10"/><polyline points="17 8 22 12 17 16"/><line x1="22" y1="12" x2="11" y2="12"/></svg>`,
625
+ tree: () => `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
626
+ bar: () => `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>`,
627
+ info: () => `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
628
+ };
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Section builders (one function per report section)
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /**
635
+ * Render the gradient page header.
636
+ *
637
+ * @param {string} projectType
638
+ * @param {string} relRoot
639
+ * @returns {string}
640
+ */
641
+ function renderHeader(projectType, relRoot) {
642
+ return `
643
+ <header class="header">
644
+ <div class="header-top">
645
+ <div>
646
+ <div class="header-brand">
647
+ <span class="header-logo">T</span>
648
+ Toren
649
+ </div>
650
+ <h1>Project Report</h1>
651
+ <p class="header-subtitle">Generated automatically from codebase analysis</p>
652
+ </div>
653
+ <span class="type-badge">
654
+ ${icon.code()}
655
+ <span class="type-badge-dot"></span>
656
+ ${esc(projectType || 'Unknown')}
657
+ </span>
658
+ </div>
659
+ <div class="path-pill">
660
+ ${icon.folder()}
661
+ ${esc(relRoot)}
662
+ </div>
663
+ </header>`;
664
+ }
665
+
666
+ /**
667
+ * Render the four summary stat cards.
668
+ *
669
+ * @param {import('../scanner/scan.js').ScanResult} result
670
+ * @returns {string}
671
+ */
672
+ function renderSummaryCards(result) {
673
+ const { projectType, flatFiles, totalFolders, scanDurationMs } = result;
674
+
675
+ const cards = [
676
+ {
677
+ label: 'Project Type',
678
+ icon: icon.code(),
679
+ value: esc(projectType || 'Unknown'),
680
+ isText: true,
681
+ sub: 'detected framework',
682
+ },
683
+ {
684
+ label: 'Total Files',
685
+ icon: icon.file(),
686
+ value: flatFiles.length,
687
+ sub: 'source files',
688
+ },
689
+ {
690
+ label: 'Total Folders',
691
+ icon: icon.folder(),
692
+ value: totalFolders,
693
+ sub: 'directories',
694
+ },
695
+ {
696
+ label: 'Scan Duration',
697
+ icon: icon.clock(),
698
+ value: formatDuration(scanDurationMs),
699
+ isText: true,
700
+ sub: 'wall-clock time',
701
+ },
702
+ ];
703
+
704
+ const cardHTML = cards.map(c => `
705
+ <div class="card">
706
+ <div class="card-label">
707
+ <span class="card-icon">${c.icon}</span>
708
+ ${esc(c.label)}
709
+ </div>
710
+ <div class="card-value${c.isText ? ' is-text' : ''}">${c.value}</div>
711
+ <div class="card-sub">${esc(c.sub)}</div>
712
+ </div>`).join('');
713
+
714
+ return `<div class="cards">${cardHTML}</div>`;
715
+ }
716
+
717
+ /**
718
+ * Render the entry points section.
719
+ *
720
+ * @param {string[]} entryPoints
721
+ * @returns {string}
722
+ */
723
+ function renderEntryPoints(entryPoints) {
724
+ const count = entryPoints.length;
725
+
726
+ const body = count === 0
727
+ ? `<p class="empty-msg">No entry points detected.</p>`
728
+ : `<ul class="entry-list">
729
+ ${entryPoints.map(ep => `
730
+ <li class="entry-item">
731
+ <span class="entry-dot"></span>
732
+ ${esc(ep)}
733
+ </li>`).join('')}
734
+ </ul>`;
735
+
736
+ const countBadge = count > 0
737
+ ? `<span class="section-count">${count} found</span>`
738
+ : '';
739
+
740
+ return `
741
+ <div class="section">
742
+ <div class="section-header">
743
+ <div class="section-icon">${icon.door()}</div>
744
+ <span class="section-title">Entry Points</span>
745
+ ${countBadge}
746
+ </div>
747
+ <div class="section-body">${body}</div>
748
+ </div>`;
749
+ }
750
+
751
+ /**
752
+ * Render the folder structure section with a dark <pre><code> tree.
753
+ *
754
+ * @param {string[]} flatFiles
755
+ * @param {string} rootName
756
+ * @returns {string}
757
+ */
758
+ function renderFolderStructure(flatFiles, rootName) {
759
+ const treeStr = buildTreeString(flatFiles, rootName);
760
+
761
+ return `
762
+ <div class="section">
763
+ <div class="section-header">
764
+ <div class="section-icon">${icon.tree()}</div>
765
+ <span class="section-title">Folder Structure</span>
766
+ ${flatFiles.length > 0 ? `<span class="section-count">${flatFiles.length} files</span>` : ''}
767
+ </div>
768
+ <div class="section-body">
769
+ <div class="tree-wrap">
770
+ <pre><code>${esc(treeStr)}</code></pre>
771
+ </div>
772
+ </div>
773
+ </div>`;
774
+ }
775
+
776
+ /**
777
+ * Render the statistics table.
778
+ *
779
+ * @param {import('../scanner/scan.js').ScanResult} result
780
+ * @returns {string}
781
+ */
782
+ function renderStats(result) {
783
+ const { flatFiles, totalFolders, scanDurationMs } = result;
784
+
785
+ const rows = [
786
+ ['Files', flatFiles.length, true],
787
+ ['Folders', totalFolders, true],
788
+ ['Scan Duration', formatDuration(scanDurationMs), false],
789
+ ];
790
+
791
+ const rowsHTML = rows.map(([metric, value, isNum]) => `
792
+ <tr>
793
+ <td>${esc(metric)}</td>
794
+ <td class="${isNum ? 'val' : 'val-plain'}">${value}</td>
795
+ </tr>`).join('');
796
+
797
+ return `
798
+ <div class="section">
799
+ <div class="section-header">
800
+ <div class="section-icon">${icon.bar()}</div>
801
+ <span class="section-title">Statistics</span>
802
+ </div>
803
+ <div class="section-body">
804
+ <table class="data-table">
805
+ <thead>
806
+ <tr>
807
+ <th>Metric</th>
808
+ <th style="text-align:right">Value</th>
809
+ </tr>
810
+ </thead>
811
+ <tbody>${rowsHTML}</tbody>
812
+ </table>
813
+ </div>
814
+ </div>`;
815
+ }
816
+
817
+ /**
818
+ * Render the scan provenance / metadata section.
819
+ *
820
+ * @returns {string}
821
+ */
822
+ function renderScanInfo() {
823
+ const rows = [
824
+ ['Generated by', '<strong>Toren</strong> — Codebase Onboarding Intelligence'],
825
+ ['Output format', 'HTML'],
826
+ ];
827
+
828
+ const rowsHTML = rows.map(([label, value]) => `
829
+ <tr>
830
+ <td>${esc(label)}</td>
831
+ <td class="val-plain">${value}</td>
832
+ </tr>`).join('');
833
+
834
+ return `
835
+ <div class="section">
836
+ <div class="section-header">
837
+ <div class="section-icon">${icon.info()}</div>
838
+ <span class="section-title">Scan Information</span>
839
+ </div>
840
+ <div class="section-body">
841
+ <table class="data-table">
842
+ <tbody>${rowsHTML}</tbody>
843
+ </table>
844
+ </div>
845
+ </div>`;
846
+ }
847
+
848
+ /**
849
+ * Render the page footer.
850
+ *
851
+ * @returns {string}
852
+ */
853
+ function renderFooter() {
854
+ return `
855
+ <footer class="footer">
856
+ <span class="footer-brand">${icon.code()} Toren</span>
857
+ &nbsp;—&nbsp; Codebase Onboarding Intelligence
858
+ </footer>`;
859
+ }
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Public API
863
+ // ---------------------------------------------------------------------------
864
+
865
+ /**
866
+ * Render a ScanResult as a self-contained HTML5 report to stdout.
867
+ *
868
+ * The entire document is assembled into a single string before writing —
869
+ * one console.log call keeps stdout writes atomic and avoids partial output
870
+ * if the process is terminated early.
871
+ *
872
+ * @param {import('../scanner/scan.js').ScanResult} result
873
+ * @param {{ cwd?: string }} [options]
874
+ */
875
+ export function render(result, options = {}) {
876
+ const { rootPath, projectType, entryPoints, flatFiles } = result;
877
+
878
+ const cwd = options.cwd ?? process.cwd();
879
+ const relRoot = path.relative(cwd, rootPath) || '.';
880
+ const rootName = path.basename(rootPath) || relRoot;
881
+ const title = `Toren — ${esc(path.basename(rootPath) || relRoot)}`;
882
+
883
+ const html = `<!DOCTYPE html>
884
+ <html lang="en">
885
+ <head>
886
+ <meta charset="UTF-8">
887
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
888
+ <meta name="description" content="Toren project analysis report for ${esc(relRoot)}">
889
+ <meta name="generator" content="Toren">
890
+ <title>${title}</title>
891
+ ${buildStyles()}
892
+ </head>
893
+ <body>
894
+ <div class="page">
895
+
896
+ ${renderHeader(projectType, relRoot)}
897
+
898
+ <main>
899
+ ${renderSummaryCards(result)}
900
+ ${renderEntryPoints(entryPoints)}
901
+ ${renderFolderStructure(flatFiles, rootName)}
902
+ ${renderStats(result)}
903
+ ${renderScanInfo()}
904
+ </main>
905
+
906
+ ${renderFooter()}
907
+
908
+ </div>
909
+ </body>
910
+ </html>`;
911
+
912
+ console.log(html);
913
+ }