@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.
- package/LICENSE +21 -0
- package/README.md +354 -0
- package/bin/toren.js +203 -0
- package/package.json +39 -0
- package/src/lifecycle.js +124 -0
- package/src/renderers/console-renderer.js +218 -0
- package/src/renderers/html-renderer.js +913 -0
- package/src/renderers/index.js +41 -0
- package/src/renderers/json-renderer.js +54 -0
- package/src/renderers/markdown-renderer.js +328 -0
- package/src/renderers/tree-renderer.js +67 -0
- package/src/scanner/scan.js +341 -0
|
@@ -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, '&')
|
|
143
|
+
.replace(/</g, '<')
|
|
144
|
+
.replace(/>/g, '>')
|
|
145
|
+
.replace(/"/g, '"')
|
|
146
|
+
.replace(/'/g, ''');
|
|
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 '< 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
|
+
— 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
|
+
}
|