next-route-graph 1.0.2 → 1.2.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/CHANGELOG.md +95 -0
- package/CONTRIBUTING.md +74 -0
- package/README.md +169 -134
- package/SECURITY.md +19 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +126 -44
- package/dist/core/analyzer.d.ts +16 -0
- package/dist/core/analyzer.d.ts.map +1 -0
- package/dist/core/analyzer.js +113 -29
- package/dist/core/detector.d.ts +19 -0
- package/dist/core/detector.d.ts.map +1 -0
- package/dist/core/detector.js +31 -3
- package/dist/core/inspect.d.ts +6 -0
- package/dist/core/inspect.d.ts.map +1 -0
- package/dist/core/inspect.js +21 -0
- package/dist/core/linter.d.ts +11 -0
- package/dist/core/linter.d.ts.map +1 -0
- package/dist/core/linter.js +194 -0
- package/dist/core/parser.d.ts +95 -0
- package/dist/core/parser.d.ts.map +1 -0
- package/dist/core/parser.js +242 -15
- package/dist/core/scanner.d.ts +12 -0
- package/dist/core/scanner.d.ts.map +1 -0
- package/dist/core/scanner.js +32 -6
- package/dist/errors/index.d.ts +35 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +65 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/preview.d.ts +4 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +25 -0
- package/dist/render/dot.d.ts +13 -0
- package/dist/render/dot.d.ts.map +1 -0
- package/dist/render/dot.js +66 -0
- package/dist/render/flat.d.ts +13 -0
- package/dist/render/flat.d.ts.map +1 -0
- package/dist/render/flat.js +14 -2
- package/dist/render/graph.d.ts +13 -0
- package/dist/render/graph.d.ts.map +1 -0
- package/dist/render/graph.js +53 -0
- package/dist/render/html.d.ts +8 -0
- package/dist/render/html.d.ts.map +1 -0
- package/dist/render/html.js +718 -0
- package/dist/render/index.d.ts +20 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +37 -0
- package/dist/render/json.d.ts +13 -0
- package/dist/render/json.d.ts.map +1 -0
- package/dist/render/json.js +12 -14
- package/dist/render/markdown.d.ts +13 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +59 -0
- package/dist/render/mermaid.d.ts +13 -0
- package/dist/render/mermaid.d.ts.map +1 -0
- package/dist/render/mermaid.js +64 -0
- package/dist/render/renderer.interface.d.ts +8 -0
- package/dist/render/renderer.interface.d.ts.map +1 -0
- package/dist/render/renderer.interface.js +1 -0
- package/dist/render/svg.d.ts +8 -0
- package/dist/render/svg.d.ts.map +1 -0
- package/dist/render/svg.js +76 -0
- package/dist/render/tree.d.ts +13 -0
- package/dist/render/tree.d.ts.map +1 -0
- package/dist/render/tree.js +57 -7
- package/dist/types/index.d.ts +132 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ARCHITECTURE.md +71 -0
- package/docs/NEW_FEATURES.md +16 -0
- package/docs/adr/0001-offline-reports-and-shared-route-graph.md +29 -0
- package/docs/route-report.png +0 -0
- package/package.json +71 -52
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { renderDot } from "./dot.js";
|
|
2
|
+
import { renderFlat } from "./flat.js";
|
|
3
|
+
import { buildRouteGraph, escapeMarkup as esc } from "./graph.js";
|
|
4
|
+
import { renderJson } from "./json.js";
|
|
5
|
+
import { renderMarkdown } from "./markdown.js";
|
|
6
|
+
import { renderMermaid } from "./mermaid.js";
|
|
7
|
+
import { renderSvg } from "./svg.js";
|
|
8
|
+
import { renderTree } from "./tree.js";
|
|
9
|
+
import { stripVTControlCharacters } from "node:util";
|
|
10
|
+
/** Self-contained report. All source data is escaped; no project code is executed. */
|
|
11
|
+
export function renderHtml(result) {
|
|
12
|
+
const svg = renderSvg(result);
|
|
13
|
+
const sources = {
|
|
14
|
+
svg: { text: svg, extension: "svg", mime: "image/svg+xml" },
|
|
15
|
+
mermaid: {
|
|
16
|
+
text: renderMermaid(result).split("\n").slice(1, -1).join("\n"),
|
|
17
|
+
extension: "mmd",
|
|
18
|
+
mime: "text/plain",
|
|
19
|
+
},
|
|
20
|
+
dot: {
|
|
21
|
+
text: renderDot(result),
|
|
22
|
+
extension: "dot",
|
|
23
|
+
mime: "text/vnd.graphviz",
|
|
24
|
+
},
|
|
25
|
+
markdown: {
|
|
26
|
+
text: renderMarkdown(result),
|
|
27
|
+
extension: "md",
|
|
28
|
+
mime: "text/markdown",
|
|
29
|
+
},
|
|
30
|
+
json: {
|
|
31
|
+
text: renderJson(result),
|
|
32
|
+
extension: "json",
|
|
33
|
+
mime: "application/json",
|
|
34
|
+
},
|
|
35
|
+
flat: { text: renderFlat(result), extension: "txt", mime: "text/plain" },
|
|
36
|
+
tree: {
|
|
37
|
+
text: stripVTControlCharacters(renderTree(result)),
|
|
38
|
+
extension: "txt",
|
|
39
|
+
mime: "text/plain",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const payload = JSON.stringify({
|
|
43
|
+
result,
|
|
44
|
+
nodes: buildRouteGraph(result.routes),
|
|
45
|
+
sources,
|
|
46
|
+
}).replace(/</g, "\\u003c");
|
|
47
|
+
const issues = result.lint?.issues ?? [];
|
|
48
|
+
const stats = [
|
|
49
|
+
["Endpoints", result.routes.length],
|
|
50
|
+
["Server", result.summary.server],
|
|
51
|
+
["Client", result.summary.client],
|
|
52
|
+
["Dynamic", result.summary.dynamic],
|
|
53
|
+
["API handlers", result.summary.apiRoutes ?? 0],
|
|
54
|
+
["Layouts", result.summary.layouts],
|
|
55
|
+
];
|
|
56
|
+
return `<!DOCTYPE html>
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="UTF-8">
|
|
60
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
61
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; base-uri 'none'; form-action 'none'">
|
|
62
|
+
<title>next-route-graph | ${esc(result.project)}</title>
|
|
63
|
+
<style>
|
|
64
|
+
:root {
|
|
65
|
+
color-scheme: dark;
|
|
66
|
+
--bg: #0b1020;
|
|
67
|
+
--surface: #111a2b;
|
|
68
|
+
--line: #2b3851;
|
|
69
|
+
--text: #edf2fa;
|
|
70
|
+
--muted: #a6b4ca;
|
|
71
|
+
--accent: #67e8f9;
|
|
72
|
+
font-family:
|
|
73
|
+
system-ui,
|
|
74
|
+
-apple-system,
|
|
75
|
+
Segoe UI,
|
|
76
|
+
sans-serif;
|
|
77
|
+
}
|
|
78
|
+
* {
|
|
79
|
+
box-sizing: border-box;
|
|
80
|
+
}
|
|
81
|
+
body {
|
|
82
|
+
margin: 0;
|
|
83
|
+
background: var(--bg);
|
|
84
|
+
color: var(--text);
|
|
85
|
+
line-height: 1.5;
|
|
86
|
+
}
|
|
87
|
+
main {
|
|
88
|
+
max-width: 1480px;
|
|
89
|
+
margin: auto;
|
|
90
|
+
padding: 32px;
|
|
91
|
+
}
|
|
92
|
+
h1,
|
|
93
|
+
h2,
|
|
94
|
+
h3,
|
|
95
|
+
p {
|
|
96
|
+
margin: 0 0 12px;
|
|
97
|
+
}
|
|
98
|
+
h1 {
|
|
99
|
+
font-size: 24px;
|
|
100
|
+
letter-spacing: -0.7px;
|
|
101
|
+
}
|
|
102
|
+
h2 {
|
|
103
|
+
font-size: 18px;
|
|
104
|
+
}
|
|
105
|
+
h3 {
|
|
106
|
+
font-size: 15px;
|
|
107
|
+
}
|
|
108
|
+
header {
|
|
109
|
+
display: flex;
|
|
110
|
+
justify-content: space-between;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 20px;
|
|
113
|
+
margin-bottom: 28px;
|
|
114
|
+
}
|
|
115
|
+
.eyebrow {
|
|
116
|
+
color: var(--accent);
|
|
117
|
+
font-size: 11px;
|
|
118
|
+
letter-spacing: 2px;
|
|
119
|
+
font-weight: 700;
|
|
120
|
+
margin-bottom: 6px;
|
|
121
|
+
}
|
|
122
|
+
.muted,
|
|
123
|
+
small {
|
|
124
|
+
color: var(--muted);
|
|
125
|
+
}
|
|
126
|
+
code,
|
|
127
|
+
pre,
|
|
128
|
+
.mono {
|
|
129
|
+
font-family: ui-monospace, Consolas, monospace;
|
|
130
|
+
}
|
|
131
|
+
code {
|
|
132
|
+
overflow-wrap: anywhere;
|
|
133
|
+
}
|
|
134
|
+
button,
|
|
135
|
+
input,
|
|
136
|
+
select {
|
|
137
|
+
font: inherit;
|
|
138
|
+
}
|
|
139
|
+
button,
|
|
140
|
+
select {
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
}
|
|
143
|
+
button,
|
|
144
|
+
select,
|
|
145
|
+
input {
|
|
146
|
+
border: 1px solid var(--line);
|
|
147
|
+
border-radius: 7px;
|
|
148
|
+
background: #172237;
|
|
149
|
+
color: var(--text);
|
|
150
|
+
padding: 9px 13px;
|
|
151
|
+
}
|
|
152
|
+
button:hover {
|
|
153
|
+
border-color: var(--accent);
|
|
154
|
+
}
|
|
155
|
+
button:focus-visible,
|
|
156
|
+
input:focus-visible,
|
|
157
|
+
select:focus-visible,
|
|
158
|
+
summary:focus-visible {
|
|
159
|
+
outline: 2px solid var(--accent);
|
|
160
|
+
outline-offset: 3px;
|
|
161
|
+
}
|
|
162
|
+
button[aria-current="page"] {
|
|
163
|
+
background: #164155;
|
|
164
|
+
border-color: var(--accent);
|
|
165
|
+
color: var(--accent);
|
|
166
|
+
}
|
|
167
|
+
.stats {
|
|
168
|
+
display: grid;
|
|
169
|
+
grid-template-columns: repeat(6, 1fr);
|
|
170
|
+
gap: 12px;
|
|
171
|
+
margin-bottom: 24px;
|
|
172
|
+
}
|
|
173
|
+
.stat {
|
|
174
|
+
padding: 16px;
|
|
175
|
+
border: 1px solid var(--line);
|
|
176
|
+
border-radius: 10px;
|
|
177
|
+
background: var(--surface);
|
|
178
|
+
}
|
|
179
|
+
.stat b {
|
|
180
|
+
display: block;
|
|
181
|
+
font-size: 26px;
|
|
182
|
+
line-height: 1.2;
|
|
183
|
+
}
|
|
184
|
+
.stat span {
|
|
185
|
+
font-size: 12px;
|
|
186
|
+
color: var(--muted);
|
|
187
|
+
}
|
|
188
|
+
nav {
|
|
189
|
+
display: flex;
|
|
190
|
+
gap: 8px;
|
|
191
|
+
flex-wrap: wrap;
|
|
192
|
+
margin-bottom: 20px;
|
|
193
|
+
}
|
|
194
|
+
.toolbar {
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
flex-wrap: wrap;
|
|
198
|
+
gap: 10px;
|
|
199
|
+
margin-bottom: 16px;
|
|
200
|
+
}
|
|
201
|
+
.toolbar input {
|
|
202
|
+
flex: 1;
|
|
203
|
+
min-width: 180px;
|
|
204
|
+
}
|
|
205
|
+
.toolbar label {
|
|
206
|
+
font-size: 13px;
|
|
207
|
+
color: var(--muted);
|
|
208
|
+
}
|
|
209
|
+
.card {
|
|
210
|
+
background: var(--surface);
|
|
211
|
+
border: 1px solid var(--line);
|
|
212
|
+
border-radius: 10px;
|
|
213
|
+
padding: 20px;
|
|
214
|
+
min-width: 0;
|
|
215
|
+
}
|
|
216
|
+
.workspace {
|
|
217
|
+
display: grid;
|
|
218
|
+
grid-template-columns: minmax(0, 1fr) 340px;
|
|
219
|
+
gap: 16px;
|
|
220
|
+
}
|
|
221
|
+
.map-scroll {
|
|
222
|
+
max-height: 68vh;
|
|
223
|
+
overflow: auto;
|
|
224
|
+
border-radius: 8px;
|
|
225
|
+
}
|
|
226
|
+
.map-scroll svg {
|
|
227
|
+
display: block;
|
|
228
|
+
min-width: 100%;
|
|
229
|
+
height: auto;
|
|
230
|
+
}
|
|
231
|
+
.map-scroll g[aria-pressed="true"] rect:first-of-type {
|
|
232
|
+
stroke: var(--accent);
|
|
233
|
+
stroke-width: 2;
|
|
234
|
+
}
|
|
235
|
+
.map-scroll g.dim {
|
|
236
|
+
opacity: 0.22;
|
|
237
|
+
}
|
|
238
|
+
.detail {
|
|
239
|
+
max-height: 72vh;
|
|
240
|
+
overflow: auto;
|
|
241
|
+
}
|
|
242
|
+
.detail article + article {
|
|
243
|
+
border-top: 1px solid var(--line);
|
|
244
|
+
padding-top: 18px;
|
|
245
|
+
margin-top: 18px;
|
|
246
|
+
}
|
|
247
|
+
.detail dt {
|
|
248
|
+
font-size: 11px;
|
|
249
|
+
text-transform: uppercase;
|
|
250
|
+
letter-spacing: 1px;
|
|
251
|
+
color: var(--muted);
|
|
252
|
+
margin-top: 14px;
|
|
253
|
+
}
|
|
254
|
+
.detail dd {
|
|
255
|
+
margin: 4px 0;
|
|
256
|
+
font-size: 13px;
|
|
257
|
+
overflow-wrap: anywhere;
|
|
258
|
+
}
|
|
259
|
+
.detail ol,
|
|
260
|
+
.detail ul {
|
|
261
|
+
padding-left: 20px;
|
|
262
|
+
font-size: 13px;
|
|
263
|
+
}
|
|
264
|
+
.badge {
|
|
265
|
+
display: inline-block;
|
|
266
|
+
padding: 2px 7px;
|
|
267
|
+
border: 1px solid var(--line);
|
|
268
|
+
border-radius: 4px;
|
|
269
|
+
font:
|
|
270
|
+
11px ui-monospace,
|
|
271
|
+
Consolas,
|
|
272
|
+
monospace;
|
|
273
|
+
color: #c7d2fe;
|
|
274
|
+
margin: 2px;
|
|
275
|
+
}
|
|
276
|
+
.badge.client {
|
|
277
|
+
color: var(--accent);
|
|
278
|
+
}
|
|
279
|
+
.badge.route {
|
|
280
|
+
color: #d8b4fe;
|
|
281
|
+
}
|
|
282
|
+
.table-wrap {
|
|
283
|
+
overflow: auto;
|
|
284
|
+
}
|
|
285
|
+
table {
|
|
286
|
+
border-collapse: collapse;
|
|
287
|
+
width: 100%;
|
|
288
|
+
text-align: left;
|
|
289
|
+
font-size: 13px;
|
|
290
|
+
}
|
|
291
|
+
caption {
|
|
292
|
+
text-align: left;
|
|
293
|
+
color: var(--muted);
|
|
294
|
+
padding-bottom: 12px;
|
|
295
|
+
}
|
|
296
|
+
th,
|
|
297
|
+
td {
|
|
298
|
+
padding: 12px 10px;
|
|
299
|
+
border-bottom: 1px solid var(--line);
|
|
300
|
+
vertical-align: top;
|
|
301
|
+
}
|
|
302
|
+
th {
|
|
303
|
+
color: var(--muted);
|
|
304
|
+
font-weight: 500;
|
|
305
|
+
}
|
|
306
|
+
td button {
|
|
307
|
+
font-family: ui-monospace, Consolas, monospace;
|
|
308
|
+
text-align: left;
|
|
309
|
+
white-space: normal;
|
|
310
|
+
overflow-wrap: anywhere;
|
|
311
|
+
}
|
|
312
|
+
.issue {
|
|
313
|
+
border-left: 3px solid #fbbf24;
|
|
314
|
+
padding: 16px 18px;
|
|
315
|
+
margin: 14px 0;
|
|
316
|
+
background: #172237;
|
|
317
|
+
border-radius: 5px;
|
|
318
|
+
overflow-wrap: anywhere;
|
|
319
|
+
}
|
|
320
|
+
.issue.error {
|
|
321
|
+
border-color: #f87171;
|
|
322
|
+
}
|
|
323
|
+
.issue.info {
|
|
324
|
+
border-color: var(--accent);
|
|
325
|
+
}
|
|
326
|
+
pre {
|
|
327
|
+
white-space: pre;
|
|
328
|
+
overflow: auto;
|
|
329
|
+
max-height: 65vh;
|
|
330
|
+
background: #080d18;
|
|
331
|
+
padding: 18px;
|
|
332
|
+
border-radius: 8px;
|
|
333
|
+
font-size: 12px;
|
|
334
|
+
}
|
|
335
|
+
details {
|
|
336
|
+
margin-top: 12px;
|
|
337
|
+
}
|
|
338
|
+
summary {
|
|
339
|
+
cursor: pointer;
|
|
340
|
+
color: var(--muted);
|
|
341
|
+
}
|
|
342
|
+
.legend {
|
|
343
|
+
display: flex;
|
|
344
|
+
gap: 16px;
|
|
345
|
+
flex-wrap: wrap;
|
|
346
|
+
color: var(--muted);
|
|
347
|
+
font-size: 12px;
|
|
348
|
+
margin-top: 12px;
|
|
349
|
+
}
|
|
350
|
+
.legend span::before {
|
|
351
|
+
content: "●";
|
|
352
|
+
color: #a5b4fc;
|
|
353
|
+
margin-right: 6px;
|
|
354
|
+
}
|
|
355
|
+
.legend .client::before {
|
|
356
|
+
color: var(--accent);
|
|
357
|
+
}
|
|
358
|
+
.legend .api::before {
|
|
359
|
+
color: #d8b4fe;
|
|
360
|
+
}
|
|
361
|
+
footer {
|
|
362
|
+
font-size: 12px;
|
|
363
|
+
color: var(--muted);
|
|
364
|
+
margin-top: 24px;
|
|
365
|
+
}
|
|
366
|
+
#status {
|
|
367
|
+
min-height: 24px;
|
|
368
|
+
font-size: 13px;
|
|
369
|
+
color: var(--accent);
|
|
370
|
+
margin-top: 12px;
|
|
371
|
+
}
|
|
372
|
+
[hidden] {
|
|
373
|
+
display: none !important;
|
|
374
|
+
}
|
|
375
|
+
@media (max-width: 950px) {
|
|
376
|
+
.workspace {
|
|
377
|
+
grid-template-columns: 1fr;
|
|
378
|
+
}
|
|
379
|
+
.detail {
|
|
380
|
+
max-height: none;
|
|
381
|
+
}
|
|
382
|
+
.stats {
|
|
383
|
+
grid-template-columns: repeat(3, 1fr);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
@media (max-width: 550px) {
|
|
387
|
+
main {
|
|
388
|
+
padding: 16px;
|
|
389
|
+
}
|
|
390
|
+
header {
|
|
391
|
+
display: block;
|
|
392
|
+
}
|
|
393
|
+
.stats {
|
|
394
|
+
gap: 8px;
|
|
395
|
+
}
|
|
396
|
+
.stat {
|
|
397
|
+
padding: 12px;
|
|
398
|
+
}
|
|
399
|
+
.stat b {
|
|
400
|
+
font-size: 22px;
|
|
401
|
+
}
|
|
402
|
+
.toolbar {
|
|
403
|
+
align-items: stretch;
|
|
404
|
+
}
|
|
405
|
+
.toolbar input {
|
|
406
|
+
min-width: 100%;
|
|
407
|
+
}
|
|
408
|
+
.card {
|
|
409
|
+
padding: 14px;
|
|
410
|
+
}
|
|
411
|
+
nav button {
|
|
412
|
+
flex: 1;
|
|
413
|
+
}
|
|
414
|
+
header small {
|
|
415
|
+
display: block;
|
|
416
|
+
margin-top: 8px;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
@media print {
|
|
420
|
+
nav,
|
|
421
|
+
.toolbar,
|
|
422
|
+
footer,
|
|
423
|
+
#status {
|
|
424
|
+
display: none;
|
|
425
|
+
}
|
|
426
|
+
.panel[hidden] {
|
|
427
|
+
display: block !important;
|
|
428
|
+
}
|
|
429
|
+
.workspace {
|
|
430
|
+
display: block;
|
|
431
|
+
}
|
|
432
|
+
.map-scroll,
|
|
433
|
+
.detail,
|
|
434
|
+
pre {
|
|
435
|
+
max-height: none;
|
|
436
|
+
}
|
|
437
|
+
body {
|
|
438
|
+
background: white;
|
|
439
|
+
color: black;
|
|
440
|
+
}
|
|
441
|
+
.card {
|
|
442
|
+
break-inside: avoid;
|
|
443
|
+
}
|
|
444
|
+
.panel {
|
|
445
|
+
margin-bottom: 24px;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
</style>
|
|
449
|
+
</head>
|
|
450
|
+
<body>
|
|
451
|
+
<main>
|
|
452
|
+
<header><div><p class="eyebrow">NEXT.JS / APP ROUTER</p><h1>${esc(result.project)}</h1><p class="muted"><code>${esc(result.root)}</code> · Route architecture report</p></div><small>next-route-graph · ${result.executionTimeMs} ms · Offline report</small></header>
|
|
453
|
+
<section class="stats" aria-label="Route summary">${stats.map(([label, value]) => `<div class="stat"><b>${value}</b><span>${label}</span></div>`).join("")}</section>
|
|
454
|
+
<nav aria-label="Report sections">
|
|
455
|
+
<button data-panel="map" aria-current="page" aria-controls="map">Route map</button>
|
|
456
|
+
<button data-panel="routes" aria-controls="routes">Routes (${result.routes.length})</button>
|
|
457
|
+
<button data-panel="health" aria-controls="health">Health (${issues.length})</button>
|
|
458
|
+
<button data-panel="exports" aria-controls="exports">Exports</button>
|
|
459
|
+
</nav>
|
|
460
|
+
<div id="filters" class="toolbar">
|
|
461
|
+
<label for="search">Find routes</label><input id="search" type="search" placeholder="Path, file, HTTP method, or layout…">
|
|
462
|
+
<label for="kind">Show</label><select id="kind"><option value="all">All endpoints</option><option value="page">Pages</option><option value="route">API handlers</option><option value="client">Client</option><option value="server">Server</option><option value="dynamic">Dynamic</option><option value="parallel">Parallel slots</option><option value="intercepted">Intercepted</option></select>
|
|
463
|
+
<span id="match-count" role="status" class="muted">${result.routes.length} endpoints</span>
|
|
464
|
+
</div>
|
|
465
|
+
<section id="map" class="panel" aria-label="Route map">
|
|
466
|
+
<div class="workspace"><div class="card"><h2>URL hierarchy</h2><p class="muted">Select a node to inspect its source and layout chain. Branches include segments without pages.</p><div class="map-scroll">${svg.replace('role="img"', 'role="group"')}</div><div class="legend"><span>Server page</span><span class="client">Client page</span><span class="api">API handler</span></div></div>
|
|
467
|
+
<aside class="card detail" id="detail" aria-label="Selected route"><h2>Route details</h2><p class="muted">Select a route in the map or route table.</p></aside></div>
|
|
468
|
+
</section>
|
|
469
|
+
<section id="routes" class="panel card" hidden aria-label="Route table">
|
|
470
|
+
<div class="toolbar"><h2>Endpoints</h2><button id="csv">Download filtered CSV</button></div>
|
|
471
|
+
<div class="table-wrap"><table><caption>Every endpoint source is retained, including parallel and intercepted variants.</caption><thead><tr><th scope="col">Path</th><th scope="col">Type</th><th scope="col">Rendering</th><th scope="col">Methods / segment</th><th scope="col">Source file</th></tr></thead><tbody>
|
|
472
|
+
${result.routes.map((route, index) => `<tr data-route-index="${index}"><td><button data-select-route="${index}">${esc(route.path)}</button></td><td><span class="badge ${route.type}">${route.type}</span></td><td><span class="badge ${route.rendering}">${route.rendering}</span></td><td>${esc(route.methods?.join(", ") || route.segmentType)}${route.parallelSlots?.map((slot) => `<span class="badge">${esc(slot)}</span>`).join("") ?? ""}${route.intercepting ? '<span class="badge">intercepted</span>' : ""}</td><td><code>${esc(route.file)}</code></td></tr>`).join("")}
|
|
473
|
+
</tbody></table></div><p id="no-matches" class="muted"${result.routes.length ? " hidden" : ""}>No matching endpoints. Clear the search or choose another filter.</p>
|
|
474
|
+
</section>
|
|
475
|
+
<section id="health" class="panel card" hidden aria-label="Route health"><h2>Project health</h2><p class="muted">${result.lint ? `${result.lint.errorCount} errors · ${result.lint.warningCount} warnings` : "No lint results available"}. Diagnostics cover the full project, even when routes are filtered.</p>
|
|
476
|
+
${issues.length ? issues.map((issue) => `<article class="issue ${issue.severity}"><h3>${esc(issue.rule)} <span class="badge">${issue.severity}</span></h3><p>${esc(issue.message)}</p>${issue.filePath || issue.routePath ? `<p><code>${esc(issue.filePath ?? issue.routePath ?? "")}</code></p>` : ""}${issue.hint ? `<p class="muted">${esc(issue.hint)}</p>` : ""}</article>`).join("") : `<p>${result.lint ? "No routing issues found by the available static checks." : "Run route linting to populate this section."}</p>`}
|
|
477
|
+
</section>
|
|
478
|
+
<section id="exports" class="panel card" hidden aria-label="Export routes"><h2>Share the route map</h2><p class="muted">SVG is a rendered vector image. Mermaid and DOT are source formats for documentation and graph tools. Downloads contain all endpoints in this report; CSV follows the current filter.</p>
|
|
479
|
+
<div class="toolbar"><label for="export-format">Format</label><select id="export-format"><option value="svg">SVG image</option><option value="mermaid">Mermaid source</option><option value="dot">Graphviz DOT source</option><option value="markdown">Markdown table</option><option value="json">JSON</option><option value="flat">Flat paths</option><option value="tree">Terminal tree (plain text)</option></select><button id="download">Download</button><button id="copy">Copy source</button></div>
|
|
480
|
+
<details><summary>View export source</summary><pre id="source">${esc(svg)}</pre></details>
|
|
481
|
+
</section>
|
|
482
|
+
<p id="status" role="status" aria-live="polite"></p>
|
|
483
|
+
<noscript><p>This report’s interactive controls require JavaScript. The full route table and health report are shown below.</p><style>.panel[hidden]{display:block!important}nav,#filters{display:none}</style></noscript>
|
|
484
|
+
<footer>Source inspection only · Layout chains follow files, not runtime behavior · Import graphs, rewrites, middleware, and inherited rendering configuration are not evaluated.</footer>
|
|
485
|
+
</main>
|
|
486
|
+
<script id="report-data" type="application/json">${payload}</script>
|
|
487
|
+
<script>${reportScript}</script>
|
|
488
|
+
</body>
|
|
489
|
+
</html>`;
|
|
490
|
+
}
|
|
491
|
+
// String.raw keeps browser-side regular expressions and newline escapes intact.
|
|
492
|
+
const reportScript = String.raw `
|
|
493
|
+
"use strict";
|
|
494
|
+
const { result, nodes, sources } = JSON.parse(
|
|
495
|
+
document.getElementById("report-data").textContent,
|
|
496
|
+
);
|
|
497
|
+
const byId = (id) => document.getElementById(id);
|
|
498
|
+
const status = (message) => {
|
|
499
|
+
byId("status").textContent = message;
|
|
500
|
+
};
|
|
501
|
+
let filtered = result.routes;
|
|
502
|
+
function showPanel(id) {
|
|
503
|
+
document.querySelectorAll(".panel").forEach((panel) => {
|
|
504
|
+
panel.hidden = panel.id !== id;
|
|
505
|
+
});
|
|
506
|
+
document.querySelectorAll("[data-panel]").forEach((button) => {
|
|
507
|
+
if (button.dataset.panel === id)
|
|
508
|
+
button.setAttribute("aria-current", "page");
|
|
509
|
+
else button.removeAttribute("aria-current");
|
|
510
|
+
});
|
|
511
|
+
byId("filters").hidden = id === "health" || id === "exports";
|
|
512
|
+
}
|
|
513
|
+
document
|
|
514
|
+
.querySelectorAll("[data-panel]")
|
|
515
|
+
.forEach((button) =>
|
|
516
|
+
button.addEventListener("click", () => showPanel(button.dataset.panel)),
|
|
517
|
+
);
|
|
518
|
+
function selectNode(index) {
|
|
519
|
+
const node = nodes[index];
|
|
520
|
+
if (!node) return;
|
|
521
|
+
const detail = byId("detail");
|
|
522
|
+
detail.replaceChildren();
|
|
523
|
+
const heading = document.createElement("h2");
|
|
524
|
+
heading.textContent = node.path;
|
|
525
|
+
detail.append(heading);
|
|
526
|
+
if (!node.routes.length) {
|
|
527
|
+
const note = document.createElement("p");
|
|
528
|
+
note.textContent =
|
|
529
|
+
"This segment groups child routes and has no endpoint file.";
|
|
530
|
+
detail.append(note);
|
|
531
|
+
}
|
|
532
|
+
node.routes.forEach((route) => {
|
|
533
|
+
const article = document.createElement("article");
|
|
534
|
+
const list = document.createElement("dl");
|
|
535
|
+
const fields = {
|
|
536
|
+
Source: route.file,
|
|
537
|
+
Endpoint:
|
|
538
|
+
route.type + " · " + route.rendering + " · " + route.segmentType,
|
|
539
|
+
"HTTP methods": route.methods && route.methods.join(", "),
|
|
540
|
+
"Runtime (declared)": route.runtime,
|
|
541
|
+
"Dynamic mode (declared)": route.dynamicConfig,
|
|
542
|
+
"Parallel slots": route.parallelSlots && route.parallelSlots.join(", "),
|
|
543
|
+
Interception: route.intercepting,
|
|
544
|
+
"Server actions": route.hasServerActions
|
|
545
|
+
? "Detected in this file"
|
|
546
|
+
: undefined,
|
|
547
|
+
};
|
|
548
|
+
Object.entries(fields).forEach(([label, value]) => {
|
|
549
|
+
if (!value) return;
|
|
550
|
+
const term = document.createElement("dt");
|
|
551
|
+
term.textContent = label;
|
|
552
|
+
const description = document.createElement("dd");
|
|
553
|
+
description.textContent = value;
|
|
554
|
+
list.append(term, description);
|
|
555
|
+
});
|
|
556
|
+
article.append(list);
|
|
557
|
+
[
|
|
558
|
+
["Layout chain (outer → inner)", route.layoutChain || []],
|
|
559
|
+
[
|
|
560
|
+
"Convention files in this segment",
|
|
561
|
+
(route.files || []).map((file) => file.relativePath),
|
|
562
|
+
],
|
|
563
|
+
].forEach(([label, values]) => {
|
|
564
|
+
const title = document.createElement("h3");
|
|
565
|
+
title.textContent = label;
|
|
566
|
+
title.style.marginTop = "18px";
|
|
567
|
+
const ordered = document.createElement("ol");
|
|
568
|
+
(values.length ? values : ["None"]).forEach((value) => {
|
|
569
|
+
const item = document.createElement("li");
|
|
570
|
+
item.textContent = value;
|
|
571
|
+
ordered.append(item);
|
|
572
|
+
});
|
|
573
|
+
article.append(title, ordered);
|
|
574
|
+
});
|
|
575
|
+
detail.append(article);
|
|
576
|
+
});
|
|
577
|
+
document
|
|
578
|
+
.querySelectorAll("[data-node-index]")
|
|
579
|
+
.forEach((group) =>
|
|
580
|
+
group.setAttribute(
|
|
581
|
+
"aria-pressed",
|
|
582
|
+
String(Number(group.dataset.nodeIndex) === index),
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
document.querySelectorAll("[data-node-index]").forEach((group) => {
|
|
587
|
+
group.addEventListener("click", () =>
|
|
588
|
+
selectNode(Number(group.dataset.nodeIndex)),
|
|
589
|
+
);
|
|
590
|
+
group.addEventListener("keydown", (event) => {
|
|
591
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
selectNode(Number(group.dataset.nodeIndex));
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
document.querySelectorAll("[data-select-route]").forEach((button) =>
|
|
598
|
+
button.addEventListener("click", () => {
|
|
599
|
+
const route = result.routes[Number(button.dataset.selectRoute)];
|
|
600
|
+
const index = nodes.findIndex((node) => node.path === route.path);
|
|
601
|
+
selectNode(index);
|
|
602
|
+
showPanel("map");
|
|
603
|
+
document.querySelector('[data-node-index="' + index + '"]').focus();
|
|
604
|
+
byId("detail").scrollIntoView({ block: "nearest" });
|
|
605
|
+
}),
|
|
606
|
+
);
|
|
607
|
+
function applyFilter() {
|
|
608
|
+
const query = byId("search").value.trim().toLowerCase();
|
|
609
|
+
const kind = byId("kind").value;
|
|
610
|
+
const matches = new Set();
|
|
611
|
+
filtered = result.routes.filter((route, index) => {
|
|
612
|
+
const text = [
|
|
613
|
+
route.path,
|
|
614
|
+
route.file,
|
|
615
|
+
...(route.methods || []),
|
|
616
|
+
...(route.layoutChain || []),
|
|
617
|
+
]
|
|
618
|
+
.join(" ")
|
|
619
|
+
.toLowerCase();
|
|
620
|
+
const category =
|
|
621
|
+
kind === "all" ||
|
|
622
|
+
route.type === kind ||
|
|
623
|
+
route.rendering === kind ||
|
|
624
|
+
(kind === "dynamic" && route.segmentType !== "static") ||
|
|
625
|
+
(kind === "parallel" &&
|
|
626
|
+
route.parallelSlots &&
|
|
627
|
+
route.parallelSlots.length) ||
|
|
628
|
+
(kind === "intercepted" && route.intercepting);
|
|
629
|
+
if (category && text.includes(query)) {
|
|
630
|
+
matches.add(index);
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
return false;
|
|
634
|
+
});
|
|
635
|
+
document.querySelectorAll("[data-route-index]").forEach((row) => {
|
|
636
|
+
row.hidden = !matches.has(Number(row.dataset.routeIndex));
|
|
637
|
+
});
|
|
638
|
+
const visiblePaths = new Set(["/"]);
|
|
639
|
+
filtered.forEach((route) => {
|
|
640
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
641
|
+
for (let depth = 1; depth <= segments.length; depth++)
|
|
642
|
+
visiblePaths.add("/" + segments.slice(0, depth).join("/"));
|
|
643
|
+
});
|
|
644
|
+
document
|
|
645
|
+
.querySelectorAll("[data-node-index]")
|
|
646
|
+
.forEach((group) =>
|
|
647
|
+
group.classList.toggle(
|
|
648
|
+
"dim",
|
|
649
|
+
!visiblePaths.has(nodes[Number(group.dataset.nodeIndex)].path),
|
|
650
|
+
),
|
|
651
|
+
);
|
|
652
|
+
byId("match-count").textContent =
|
|
653
|
+
filtered.length + " of " + result.routes.length + " endpoints";
|
|
654
|
+
byId("no-matches").hidden = filtered.length > 0;
|
|
655
|
+
}
|
|
656
|
+
byId("search").addEventListener("input", applyFilter);
|
|
657
|
+
byId("kind").addEventListener("change", applyFilter);
|
|
658
|
+
function download(text, filename, mime) {
|
|
659
|
+
const url = URL.createObjectURL(
|
|
660
|
+
new Blob([text], { type: mime + ";charset=utf-8" }),
|
|
661
|
+
);
|
|
662
|
+
const link = document.createElement("a");
|
|
663
|
+
link.href = url;
|
|
664
|
+
link.download = filename;
|
|
665
|
+
document.body.append(link);
|
|
666
|
+
link.click();
|
|
667
|
+
link.remove();
|
|
668
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
669
|
+
status("Downloaded " + filename);
|
|
670
|
+
}
|
|
671
|
+
byId("export-format").addEventListener("change", () => {
|
|
672
|
+
byId("source").textContent = sources[byId("export-format").value].text;
|
|
673
|
+
});
|
|
674
|
+
byId("download").addEventListener("click", () => {
|
|
675
|
+
const source = sources[byId("export-format").value];
|
|
676
|
+
download(source.text, "routes." + source.extension, source.mime);
|
|
677
|
+
});
|
|
678
|
+
byId("copy").addEventListener("click", async () => {
|
|
679
|
+
try {
|
|
680
|
+
await navigator.clipboard.writeText(
|
|
681
|
+
sources[byId("export-format").value].text,
|
|
682
|
+
);
|
|
683
|
+
status("Source copied.");
|
|
684
|
+
} catch {
|
|
685
|
+
byId("source").parentElement.open = true;
|
|
686
|
+
status("Clipboard unavailable. Select the source text or use Download.");
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
byId("csv").addEventListener("click", () => {
|
|
690
|
+
const cell = (value) => {
|
|
691
|
+
let text = String(value || "");
|
|
692
|
+
if (/^[\s]*[=+@\-\t\r\n]/.test(text)) text = "'" + text;
|
|
693
|
+
return '"' + text.replace(/"/g, '""') + '"';
|
|
694
|
+
};
|
|
695
|
+
const rows = [
|
|
696
|
+
["Path", "Type", "Rendering", "Segment", "Methods", "File"],
|
|
697
|
+
...filtered.map((route) => [
|
|
698
|
+
route.path,
|
|
699
|
+
route.type,
|
|
700
|
+
route.rendering,
|
|
701
|
+
route.segmentType,
|
|
702
|
+
(route.methods || []).join(", "),
|
|
703
|
+
route.file,
|
|
704
|
+
]),
|
|
705
|
+
];
|
|
706
|
+
download(
|
|
707
|
+
rows.map((row) => row.map(cell).join(",")).join("\r\n"),
|
|
708
|
+
"routes.csv",
|
|
709
|
+
"text/csv",
|
|
710
|
+
);
|
|
711
|
+
});
|
|
712
|
+
applyFilter();
|
|
713
|
+
`;
|
|
714
|
+
export class HtmlRenderer {
|
|
715
|
+
render(result) {
|
|
716
|
+
return renderHtml(result);
|
|
717
|
+
}
|
|
718
|
+
}
|