@nitronjs/framework 0.2.27 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +260 -170
  2. package/lib/Auth/Auth.js +2 -2
  3. package/lib/Build/CssBuilder.js +5 -7
  4. package/lib/Build/EffectivePropUsage.js +174 -0
  5. package/lib/Build/FactoryTransform.js +1 -21
  6. package/lib/Build/FileAnalyzer.js +1 -32
  7. package/lib/Build/Manager.js +354 -58
  8. package/lib/Build/PropUsageAnalyzer.js +1189 -0
  9. package/lib/Build/jsxRuntime.js +25 -155
  10. package/lib/Build/plugins.js +212 -146
  11. package/lib/Build/propUtils.js +70 -0
  12. package/lib/Console/Commands/DevCommand.js +30 -10
  13. package/lib/Console/Commands/MakeCommand.js +8 -1
  14. package/lib/Console/Output.js +0 -2
  15. package/lib/Console/Stubs/rsc-consumer.tsx +74 -0
  16. package/lib/Console/Stubs/vendor-dev.tsx +30 -41
  17. package/lib/Console/Stubs/vendor.tsx +25 -1
  18. package/lib/Core/Config.js +0 -6
  19. package/lib/Core/Paths.js +0 -19
  20. package/lib/Database/Migration/Checksum.js +0 -3
  21. package/lib/Database/Migration/MigrationRepository.js +0 -8
  22. package/lib/Database/Migration/MigrationRunner.js +1 -2
  23. package/lib/Database/Model.js +19 -11
  24. package/lib/Database/QueryBuilder.js +25 -4
  25. package/lib/Database/Schema/Blueprint.js +10 -0
  26. package/lib/Database/Schema/Manager.js +2 -0
  27. package/lib/Date/DateTime.js +1 -1
  28. package/lib/Dev/DevContext.js +44 -0
  29. package/lib/Dev/DevErrorPage.js +990 -0
  30. package/lib/Dev/DevIndicator.js +836 -0
  31. package/lib/HMR/Server.js +16 -37
  32. package/lib/Http/Server.js +171 -23
  33. package/lib/Logging/Log.js +34 -2
  34. package/lib/Mail/Mail.js +41 -10
  35. package/lib/Route/Router.js +43 -19
  36. package/lib/Runtime/Entry.js +10 -6
  37. package/lib/Session/Manager.js +103 -1
  38. package/lib/Session/Session.js +0 -4
  39. package/lib/Support/Str.js +6 -4
  40. package/lib/Translation/Lang.js +376 -32
  41. package/lib/Translation/pluralize.js +81 -0
  42. package/lib/Validation/MagicBytes.js +120 -0
  43. package/lib/Validation/Validator.js +46 -29
  44. package/lib/View/Client/hmr-client.js +100 -90
  45. package/lib/View/Client/spa.js +121 -50
  46. package/lib/View/ClientManifest.js +60 -0
  47. package/lib/View/FlightRenderer.js +100 -0
  48. package/lib/View/Layout.js +0 -3
  49. package/lib/View/PropFilter.js +81 -0
  50. package/lib/View/View.js +230 -495
  51. package/lib/index.d.ts +22 -1
  52. package/package.json +2 -2
  53. package/skeleton/config/app.js +1 -0
  54. package/skeleton/config/server.js +13 -0
  55. package/skeleton/config/session.js +3 -0
  56. package/lib/Build/HydrationBuilder.js +0 -190
  57. package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
  58. package/lib/Console/Stubs/page-hydration.tsx +0 -53
@@ -0,0 +1,990 @@
1
+ /**
2
+ * DevErrorPage — Premium developer error page for development mode.
3
+ *
4
+ * Generates a complete inline HTML document (no React dependency) with:
5
+ * - Hero section: icon, error type/message, code snippet, timing bar
6
+ * - 4 tabs: Stack Trace, Request, Route, Props
7
+ * - NitronJS icon watermark background
8
+ * - Framework vs App code separation in stack traces
9
+ * - Open in Editor (vscode://) links
10
+ * - Keyboard shortcuts (1-4 tabs, C copy, S search, F framework toggle, D theme)
11
+ * - JSON export, cURL replay, dark/light theme, error counter
12
+ *
13
+ * Only imported via dynamic import() when Environment.isDev && statusCode >= 500.
14
+ * Production never loads this module.
15
+ */
16
+
17
+ import { readFileSync } from "fs";
18
+ import { fileURLToPath } from "url";
19
+ import path from "path";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const packageJsonPath = path.join(__dirname, "../../package.json");
23
+ const FRAMEWORK_VERSION = JSON.parse(readFileSync(packageJsonPath, "utf-8")).version;
24
+ const ICON_BASE64 = "data:image/png;base64," + readFileSync(path.join(__dirname, "../View/Client/nitronjs-icon.png")).toString("base64");
25
+
26
+ const errorCounts = new Map();
27
+
28
+ const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;", "`": "&#96;" };
29
+
30
+ class DevErrorPage {
31
+
32
+ static render(error, devCtx, nonce) {
33
+ const nonceAttr = nonce ? ` nonce="${esc(nonce)}"` : "";
34
+ const frames = parseStackTrace(error.stack || "");
35
+ const firstAppFrame = frames.find(f => f.type === "app");
36
+ const errorCount = trackError(error, firstAppFrame);
37
+ const totalTime = devCtx.startTime ? performance.now() - devCtx.startTime : 0;
38
+ const middlewareTotal = (devCtx.middlewareTiming || []).reduce((s, m) => s + m.duration, 0);
39
+
40
+ const context = {
41
+ error: {
42
+ name: error.name || "Error",
43
+ message: error.message || "Unknown error",
44
+ stack: error.stack || "",
45
+ code: error.code || null,
46
+ statusCode: error.statusCode || 500
47
+ },
48
+ frames,
49
+ firstAppFrame,
50
+ errorCount,
51
+ timing: {
52
+ total: totalTime,
53
+ middleware: middlewareTotal,
54
+ controller: devCtx.controllerDuration || 0,
55
+ render: devCtx.renderDuration || 0,
56
+ middlewareDetails: devCtx.middlewareTiming || []
57
+ },
58
+ route: devCtx.route || null,
59
+ request: devCtx.request || null,
60
+ props: {
61
+ raw: devCtx.rawProps || null,
62
+ propUsage: devCtx.propUsage || null
63
+ },
64
+ viewName: devCtx.viewName || null,
65
+ env: {
66
+ nodeVersion: process.version,
67
+ frameworkVersion: FRAMEWORK_VERSION,
68
+ platform: process.platform,
69
+ arch: process.arch,
70
+ pid: process.pid,
71
+ uptime: Math.round(process.uptime()),
72
+ env: maskSecrets(process.env)
73
+ }
74
+ };
75
+
76
+ const contextJson = JSON.stringify(context, null, 2).replace(/<\/script>/gi, "<\\/script>");
77
+
78
+ return `<!DOCTYPE html>
79
+ <html lang="en" data-theme="dark">
80
+ <head>
81
+ <meta charset="UTF-8">
82
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
83
+ <title>${esc(context.error.name)}: ${esc(truncate(context.error.message, 60))} — NitronJS Dev Error</title>
84
+ ${generateStyles(nonceAttr)}
85
+ </head>
86
+ <body>
87
+ <div class="error-page">
88
+
89
+ <img src="${ICON_BASE64}" class="ep-watermark" alt="" />
90
+
91
+ <section class="ep-hero">
92
+ <div class="ep-hero-icon">
93
+ <img src="${ICON_BASE64}" width="36" height="36" alt="NitronJS" />
94
+ </div>
95
+ <div class="ep-error-type">${esc(context.error.name)}${context.error.code ? ` <span class="ep-error-code">${esc(context.error.code)}</span>` : ""}</div>
96
+ <div class="ep-error-message">${esc(context.error.message)}</div>
97
+ ${firstAppFrame ? `
98
+ <div class="ep-error-location">
99
+ <span class="ep-file">${esc(shortenPath(firstAppFrame.file))}:${firstAppFrame.line}</span>
100
+ <a href="${generateEditorLink(firstAppFrame.file, firstAppFrame.line)}" class="ep-editor-link">Open in Editor</a>
101
+ </div>` : ""}
102
+ ${generateCodeSnippet(firstAppFrame)}
103
+ ${generateTimingBar(context.timing)}
104
+ </section>
105
+
106
+ <nav class="ep-tabs">
107
+ <button class="ep-tab active" data-tab="stacktrace">
108
+ <span class="ep-tab-key">1</span> Stack Trace
109
+ </button>
110
+ <button class="ep-tab" data-tab="request">
111
+ <span class="ep-tab-key">2</span> Request
112
+ </button>
113
+ <button class="ep-tab" data-tab="route">
114
+ <span class="ep-tab-key">3</span> Route
115
+ </button>
116
+ <button class="ep-tab" data-tab="props">
117
+ <span class="ep-tab-key">4</span> Props
118
+ </button>
119
+ </nav>
120
+
121
+ <div class="ep-content">
122
+ ${generateStackTraceTab(context)}
123
+ ${generateRequestTab(context)}
124
+ ${generateRouteTab(context)}
125
+ ${generatePropsTab(context)}
126
+ </div>
127
+
128
+ <footer class="ep-footer">
129
+ <div class="ep-footer-info">
130
+ <span>NitronJS v${esc(FRAMEWORK_VERSION)}</span>
131
+ <span class="ep-divider">|</span>
132
+ <span>Node ${esc(process.version)}</span>
133
+ <span class="ep-divider">|</span>
134
+ <span>${context.error.statusCode}</span>
135
+ ${errorCount > 1 ? `<span class="ep-divider">|</span><span class="ep-error-count">Error occurred ${errorCount}x</span>` : ""}
136
+ </div>
137
+ <div class="ep-footer-actions">
138
+ <button class="ep-btn-sm" id="jsonExport" title="Export JSON">
139
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
140
+ JSON
141
+ </button>
142
+ <button class="ep-btn-sm" id="themeToggle" title="Toggle theme (D)">
143
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
144
+ </button>
145
+ <button class="ep-btn-sm" id="shortcutsToggle" title="Keyboard shortcuts (?)">
146
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="10.01"/><line x1="10" y1="10" x2="10" y2="10.01"/><line x1="14" y1="10" x2="14" y2="10.01"/><line x1="18" y1="10" x2="18" y2="10.01"/><line x1="8" y1="14" x2="16" y2="14"/></svg>
147
+ </button>
148
+ </div>
149
+ </footer>
150
+
151
+ <div class="ep-shortcuts-overlay" id="shortcutsOverlay">
152
+ <div class="ep-shortcuts-panel">
153
+ <div class="ep-shortcuts-title">Keyboard Shortcuts</div>
154
+ <div class="ep-shortcuts-grid">
155
+ <div class="ep-shortcut"><kbd>1</kbd><span>Stack Trace</span></div>
156
+ <div class="ep-shortcut"><kbd>2</kbd><span>Request</span></div>
157
+ <div class="ep-shortcut"><kbd>3</kbd><span>Route</span></div>
158
+ <div class="ep-shortcut"><kbd>4</kbd><span>Props</span></div>
159
+ <div class="ep-shortcut"><kbd>S</kbd><span>Search frames</span></div>
160
+ <div class="ep-shortcut"><kbd>F</kbd><span>Toggle framework</span></div>
161
+ <div class="ep-shortcut"><kbd>C</kbd><span>Copy stack trace</span></div>
162
+ <div class="ep-shortcut"><kbd>D</kbd><span>Toggle theme</span></div>
163
+ <div class="ep-shortcut"><kbd>?</kbd><span>This panel</span></div>
164
+ <div class="ep-shortcut"><kbd>Esc</kbd><span>Close / Clear</span></div>
165
+ </div>
166
+ </div>
167
+ </div>
168
+
169
+ </div>
170
+ ${generateScript(nonceAttr, contextJson)}
171
+ </body>
172
+ </html>`;
173
+ }
174
+ }
175
+
176
+ export default DevErrorPage;
177
+
178
+
179
+ // ─────────────────────────────────────────────────────────────────────────────
180
+ // Helper Functions
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+
183
+ function esc(str) {
184
+ if (!str) return "";
185
+ return String(str).replace(/[&<>"'`]/g, c => ESC[c]);
186
+ }
187
+
188
+ function truncate(str, len) {
189
+ if (!str) return "";
190
+ return str.length > len ? str.slice(0, len) + "..." : str;
191
+ }
192
+
193
+ function trackError(err, firstAppFrame) {
194
+ const key = `${err.message}::${firstAppFrame?.file || ""}:${firstAppFrame?.line || ""}`;
195
+
196
+ if (errorCounts.size > 500) errorCounts.clear();
197
+
198
+ const count = (errorCounts.get(key) || 0) + 1;
199
+
200
+ errorCounts.set(key, count);
201
+ return count;
202
+ }
203
+
204
+ function parseStackTrace(stack) {
205
+ const lines = stack.split("\n").slice(1);
206
+ const frames = [];
207
+
208
+ for (const line of lines) {
209
+ const trimmed = line.trim();
210
+ if (!trimmed.startsWith("at ")) continue;
211
+
212
+ const match = trimmed.match(/^at\s+(?:(.+?)\s+)?\(?(.+):(\d+):(\d+)\)?$/);
213
+ if (!match) {
214
+ const simpleMatch = trimmed.match(/^at\s+(.+)$/);
215
+ if (simpleMatch) {
216
+ frames.push({ fn: simpleMatch[1], file: "", line: 0, column: 0, type: "internal", raw: trimmed });
217
+ }
218
+ continue;
219
+ }
220
+
221
+ const fn = match[1] || "(anonymous)";
222
+ let file = match[2].replace(/^file:\/\/\//, "/").replace(/\?[^:]*$/, "");
223
+ const lineNum = parseInt(match[3], 10);
224
+ const col = parseInt(match[4], 10);
225
+ const type = classifyFrame(file);
226
+
227
+ frames.push({ fn, file, line: lineNum, column: col, type, raw: trimmed });
228
+ }
229
+
230
+ return frames;
231
+ }
232
+
233
+ function classifyFrame(filePath) {
234
+ if (!filePath) return "internal";
235
+
236
+ const normalized = filePath.replace(/\\/g, "/");
237
+
238
+ if (normalized.includes("node:") || normalized.includes("node_modules")) {
239
+ return "vendor";
240
+ }
241
+
242
+ if (normalized.includes("packages/nitronjs/lib/")) {
243
+ return "framework";
244
+ }
245
+
246
+ if (normalized.includes("/app/") || normalized.includes("/resources/")) {
247
+ return "app";
248
+ }
249
+
250
+ return "framework";
251
+ }
252
+
253
+ function generateEditorLink(file, line) {
254
+ let normalized = file.replace(/\\/g, "/");
255
+
256
+ if (/^\/[A-Za-z]:/.test(normalized)) {
257
+ normalized = normalized.slice(1);
258
+ }
259
+
260
+ return `vscode://file/${encodeURI(normalized)}:${line}`;
261
+ }
262
+
263
+ function generateTimingBar(timing) {
264
+ if (!timing.total) return "";
265
+
266
+ const total = timing.total;
267
+ const mwPct = total > 0 ? (timing.middleware / total * 100) : 0;
268
+ const ctrlPct = total > 0 ? (timing.controller / total * 100) : 0;
269
+ const renderPct = total > 0 ? (timing.render / total * 100) : 0;
270
+ const otherPct = Math.max(0, 100 - mwPct - ctrlPct - renderPct);
271
+
272
+ return `
273
+ <div class="ep-timing-bar">
274
+ <div class="ep-timing-segments">
275
+ ${mwPct > 0 ? `<div class="ep-timing-seg seg-mw" style="width:${mwPct}%" title="Middleware: ${timing.middleware.toFixed(1)}ms"></div>` : ""}
276
+ ${ctrlPct > 0 ? `<div class="ep-timing-seg seg-ctrl" style="width:${ctrlPct}%" title="Controller: ${timing.controller.toFixed(1)}ms"></div>` : ""}
277
+ ${renderPct > 0 ? `<div class="ep-timing-seg seg-render" style="width:${renderPct}%" title="Render: ${timing.render.toFixed(1)}ms"></div>` : ""}
278
+ ${otherPct > 0 ? `<div class="ep-timing-seg seg-other" style="width:${otherPct}%" title="Other: ${(total - timing.middleware - timing.controller - timing.render).toFixed(1)}ms"></div>` : ""}
279
+ </div>
280
+ <div class="ep-timing-labels">
281
+ ${mwPct > 0 ? `<span class="ep-timing-label"><span class="ep-dot seg-mw"></span>Middleware ${timing.middleware.toFixed(1)}ms</span>` : ""}
282
+ ${ctrlPct > 0 ? `<span class="ep-timing-label"><span class="ep-dot seg-ctrl"></span>Controller ${timing.controller.toFixed(1)}ms</span>` : ""}
283
+ ${renderPct > 0 ? `<span class="ep-timing-label"><span class="ep-dot seg-render"></span>Render ${timing.render.toFixed(1)}ms</span>` : ""}
284
+ <span class="ep-timing-label"><strong>${total.toFixed(1)}ms total</strong></span>
285
+ </div>
286
+ </div>`;
287
+ }
288
+
289
+ function generateCodeSnippet(frame) {
290
+ if (!frame || !frame.file || !frame.line) return "";
291
+
292
+ try {
293
+ let filePath = frame.file.replace(/^file:\/\/\//, "/");
294
+
295
+ if (/^\/[A-Za-z]:/.test(filePath)) {
296
+ filePath = filePath.slice(1);
297
+ }
298
+
299
+ const content = readFileSync(filePath, "utf-8");
300
+ const lines = content.split("\n");
301
+ const start = Math.max(0, frame.line - 4);
302
+ const end = Math.min(lines.length, frame.line + 3);
303
+
304
+ let snippetLines = "";
305
+
306
+ for (let i = start; i < end; i++) {
307
+ const lineNum = i + 1;
308
+ const isError = lineNum === frame.line;
309
+ const cls = isError ? "ep-code-line error-line" : "ep-code-line";
310
+ snippetLines += `<div class="${cls}"><span class="ep-line-num">${lineNum}</span><span class="ep-line-code">${esc(lines[i])}</span></div>`;
311
+ }
312
+
313
+ return `
314
+ <div class="ep-code-block">
315
+ <div class="ep-code-header">
316
+ <span>${esc(shortenPath(frame.file))}</span>
317
+ <a href="${generateEditorLink(frame.file, frame.line)}" class="ep-editor-link-sm">Open in Editor</a>
318
+ </div>
319
+ <div class="ep-code-body">${snippetLines}</div>
320
+ </div>`;
321
+ }
322
+ catch {
323
+ return "";
324
+ }
325
+ }
326
+
327
+ function generateStackTraceTab(ctx) {
328
+ const frames = ctx.frames;
329
+ let rows = "";
330
+
331
+ for (let i = 0; i < frames.length; i++) {
332
+ const f = frames[i];
333
+ const typeClass = `frame-${f.type}`;
334
+ const hidden = f.type === "vendor" ? " frame-hidden" : "";
335
+ const shortFile = shortenPath(f.file);
336
+
337
+ rows += `
338
+ <div class="ep-frame ${typeClass}${hidden}" data-type="${f.type}">
339
+ <div class="ep-frame-fn">${esc(f.fn)}</div>
340
+ <div class="ep-frame-loc">
341
+ <span class="ep-frame-file">${esc(shortFile)}:${f.line}:${f.column}</span>
342
+ ${f.file ? `<a href="${generateEditorLink(f.file, f.line)}" class="ep-editor-link-sm">Open</a>` : ""}
343
+ </div>
344
+ </div>`;
345
+ }
346
+
347
+ const appCount = frames.filter(f => f.type === "app").length;
348
+ const frameworkCount = frames.filter(f => f.type === "framework").length;
349
+ const vendorCount = frames.filter(f => f.type === "vendor").length;
350
+
351
+ return `
352
+ <div class="ep-tab-panel active" data-panel="stacktrace">
353
+ <div class="ep-stack-controls">
354
+ <div class="ep-search-box">
355
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
356
+ <input type="text" id="stackSearch" placeholder="Filter stack frames... (S)" class="ep-search-input">
357
+ </div>
358
+ <div class="ep-stack-toggles">
359
+ <label class="ep-toggle"><input type="checkbox" id="showFramework" checked><span>Framework <span class="ep-count">${frameworkCount}</span></span></label>
360
+ <label class="ep-toggle"><input type="checkbox" id="showVendor"><span>Vendor <span class="ep-count">${vendorCount}</span></span></label>
361
+ <span class="ep-count-label">App frames: ${appCount}</span>
362
+ </div>
363
+ <button class="ep-btn-sm" id="copyStack" title="Copy stack trace (C)">
364
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
365
+ Copy
366
+ </button>
367
+ </div>
368
+ <div class="ep-frames" id="stackFrames">${rows}</div>
369
+ </div>`;
370
+ }
371
+
372
+ function generateRequestTab(ctx) {
373
+ const req = ctx.request;
374
+ if (!req) {
375
+ return `<div class="ep-tab-panel" data-panel="request"><div class="ep-empty">No request data available</div></div>`;
376
+ }
377
+
378
+ let headersRows = "";
379
+ const headers = req.headers || {};
380
+ for (const [key, val] of Object.entries(headers)) {
381
+ const masked = isSecretHeader(key) ? "••••••••" : val;
382
+ headersRows += `<tr><td class="ep-td-key">${esc(key)}</td><td class="ep-td-val">${esc(String(masked))}</td></tr>`;
383
+ }
384
+
385
+ let queryRows = "";
386
+ const query = req.query || {};
387
+ for (const [key, val] of Object.entries(query)) {
388
+ queryRows += `<tr><td class="ep-td-key">${esc(key)}</td><td class="ep-td-val">${esc(String(val))}</td></tr>`;
389
+ }
390
+
391
+ let paramsRows = "";
392
+ const params = req.params || {};
393
+ for (const [key, val] of Object.entries(params)) {
394
+ paramsRows += `<tr><td class="ep-td-key">${esc(key)}</td><td class="ep-td-val">${esc(String(val))}</td></tr>`;
395
+ }
396
+
397
+ let cookiesRows = "";
398
+ const cookies = req.cookies || {};
399
+ for (const [key, val] of Object.entries(cookies)) {
400
+ const masked = isSecretHeader(key) ? "••••••••" : val;
401
+ cookiesRows += `<tr><td class="ep-td-key">${esc(key)}</td><td class="ep-td-val">${esc(String(masked))}</td></tr>`;
402
+ }
403
+
404
+ const bodyStr = req.body ? JSON.stringify(req.body, null, 2) : null;
405
+ const curl = generateCurlCommand(ctx);
406
+
407
+ return `
408
+ <div class="ep-tab-panel" data-panel="request">
409
+ <div class="ep-section">
410
+ <div class="ep-request-method">${esc(req.method)} <span class="ep-request-url">${esc(req.url)}</span></div>
411
+ <div class="ep-kv-group">
412
+ <div class="ep-kv"><span class="ep-key">IP</span><span class="ep-val">${esc(req.ip)}</span></div>
413
+ </div>
414
+ </div>
415
+
416
+ ${headersRows ? `
417
+ <div class="ep-section">
418
+ <div class="ep-section-title collapsible" data-collapse="headers">Headers <span class="ep-count">${Object.keys(headers).length}</span></div>
419
+ <div class="ep-collapsible" id="headers">
420
+ <table class="ep-table">${headersRows}</table>
421
+ </div>
422
+ </div>` : ""}
423
+
424
+ ${queryRows ? `
425
+ <div class="ep-section">
426
+ <div class="ep-section-title collapsible" data-collapse="query">Query Parameters <span class="ep-count">${Object.keys(query).length}</span></div>
427
+ <div class="ep-collapsible" id="query">
428
+ <table class="ep-table">${queryRows}</table>
429
+ </div>
430
+ </div>` : ""}
431
+
432
+ ${paramsRows ? `
433
+ <div class="ep-section">
434
+ <div class="ep-section-title collapsible" data-collapse="params">Route Parameters <span class="ep-count">${Object.keys(params).length}</span></div>
435
+ <div class="ep-collapsible" id="params">
436
+ <table class="ep-table">${paramsRows}</table>
437
+ </div>
438
+ </div>` : ""}
439
+
440
+ ${bodyStr ? `
441
+ <div class="ep-section">
442
+ <div class="ep-section-title collapsible" data-collapse="body">Request Body</div>
443
+ <div class="ep-collapsible" id="body">
444
+ <pre class="ep-pre">${esc(bodyStr)}</pre>
445
+ </div>
446
+ </div>` : ""}
447
+
448
+ ${cookiesRows ? `
449
+ <div class="ep-section">
450
+ <div class="ep-section-title collapsible" data-collapse="cookies">Cookies <span class="ep-count">${Object.keys(cookies).length}</span></div>
451
+ <div class="ep-collapsible" id="cookies">
452
+ <table class="ep-table">${cookiesRows}</table>
453
+ </div>
454
+ </div>` : ""}
455
+
456
+ <div class="ep-section">
457
+ <div class="ep-section-title">cURL Replay</div>
458
+ <div class="ep-curl-box">
459
+ <pre class="ep-pre ep-curl">${esc(curl)}</pre>
460
+ <button class="ep-btn-sm ep-copy-curl" data-copy="${esc(curl)}">Copy</button>
461
+ </div>
462
+ </div>
463
+ </div>`;
464
+ }
465
+
466
+ function generateRouteTab(ctx) {
467
+ const route = ctx.route;
468
+ if (!route) {
469
+ return `<div class="ep-tab-panel" data-panel="route"><div class="ep-empty">No route data available (error may have occurred before routing)</div></div>`;
470
+ }
471
+
472
+ let middlewareList = "";
473
+ const timings = ctx.timing.middlewareDetails || [];
474
+
475
+ if (route.middlewareNames && route.middlewareNames.length > 0) {
476
+ for (let i = 0; i < route.middlewareNames.length; i++) {
477
+ const name = route.middlewareNames[i];
478
+ const timing = timings[i];
479
+ const duration = timing ? ` <span class="ep-mw-time">${timing.duration.toFixed(1)}ms</span>` : "";
480
+ middlewareList += `<div class="ep-mw-item"><span class="ep-mw-index">${i + 1}</span><span class="ep-mw-name">${esc(name)}</span>${duration}</div>`;
481
+ }
482
+ }
483
+ else {
484
+ middlewareList = `<div class="ep-empty-sm">No middleware</div>`;
485
+ }
486
+
487
+ return `
488
+ <div class="ep-tab-panel" data-panel="route">
489
+ <div class="ep-section">
490
+ <div class="ep-kv-group">
491
+ <div class="ep-kv"><span class="ep-key">Name</span><span class="ep-val">${esc(route.name || "(unnamed)")}</span></div>
492
+ <div class="ep-kv"><span class="ep-key">Pattern</span><span class="ep-val mono">${esc(route.pattern)}</span></div>
493
+ <div class="ep-kv"><span class="ep-key">Method</span><span class="ep-val"><span class="ep-method-badge">${esc(route.method)}</span></span></div>
494
+ </div>
495
+ </div>
496
+ ${ctx.viewName ? `
497
+ <div class="ep-section">
498
+ <div class="ep-section-title">View</div>
499
+ <div class="ep-kv-group">
500
+ <div class="ep-kv"><span class="ep-key">View Name</span><span class="ep-val mono">${esc(ctx.viewName)}</span></div>
501
+ </div>
502
+ </div>` : ""}
503
+ <div class="ep-section">
504
+ <div class="ep-section-title">Middleware Chain</div>
505
+ <div class="ep-mw-list">${middlewareList}</div>
506
+ </div>
507
+ ${ctx.timing.total ? `
508
+ <div class="ep-section">
509
+ <div class="ep-section-title">Timing Breakdown</div>
510
+ <div class="ep-kv-group">
511
+ <div class="ep-kv"><span class="ep-key">Middleware</span><span class="ep-val">${ctx.timing.middleware.toFixed(1)}ms</span></div>
512
+ <div class="ep-kv"><span class="ep-key">Controller</span><span class="ep-val">${ctx.timing.controller.toFixed(1)}ms</span></div>
513
+ <div class="ep-kv"><span class="ep-key">Render</span><span class="ep-val">${ctx.timing.render.toFixed(1)}ms</span></div>
514
+ <div class="ep-kv"><span class="ep-key"><strong>Total</strong></span><span class="ep-val"><strong>${ctx.timing.total.toFixed(1)}ms</strong></span></div>
515
+ </div>
516
+ </div>` : ""}
517
+ </div>`;
518
+ }
519
+
520
+ function generatePropsTab(ctx) {
521
+ const raw = ctx.props.raw;
522
+ const propUsage = ctx.props.propUsage;
523
+
524
+ if (!raw && !propUsage) {
525
+ const reason = !ctx.viewName
526
+ ? "Error was thrown before res.view() was called — no props were sent."
527
+ : "No props data available.";
528
+ return `<div class="ep-tab-panel" data-panel="props"><div class="ep-empty">${reason}</div></div>`;
529
+ }
530
+
531
+ const rawJson = raw ? JSON.stringify(raw, null, 2) : "null";
532
+ const usageJson = propUsage ? JSON.stringify(propUsage, null, 2) : "null";
533
+
534
+ let filteredKeys = "";
535
+ if (raw && propUsage) {
536
+ const rawKeys = Object.keys(raw);
537
+ const usageKeys = Object.keys(propUsage);
538
+ const removed = rawKeys.filter(k => !usageKeys.includes(k));
539
+
540
+ if (removed.length > 0) {
541
+ filteredKeys = `
542
+ <div class="ep-props-filtered">
543
+ <div class="ep-section-title">Filtered Out (not delivered to client)</div>
544
+ <div class="ep-filtered-list">${removed.map(k => `<span class="ep-filtered-key">${esc(k)}</span>`).join("")}</div>
545
+ </div>`;
546
+ }
547
+ }
548
+
549
+ return `
550
+ <div class="ep-tab-panel" data-panel="props">
551
+ ${filteredKeys}
552
+ <div class="ep-props-columns">
553
+ <div class="ep-props-col">
554
+ <div class="ep-section-title">Sent by Controller</div>
555
+ <pre class="ep-pre ep-props-json">${esc(rawJson)}</pre>
556
+ </div>
557
+ <div class="ep-props-col">
558
+ <div class="ep-section-title">Prop Usage (build-time analysis)</div>
559
+ <pre class="ep-pre ep-props-json">${esc(usageJson)}</pre>
560
+ </div>
561
+ </div>
562
+ </div>`;
563
+ }
564
+
565
+ function generateCurlCommand(ctx) {
566
+ const req = ctx.request;
567
+ if (!req) return "# No request data";
568
+
569
+ let cmd = `curl -X ${req.method} 'http://localhost${req.url}'`;
570
+ const headers = req.headers || {};
571
+
572
+ for (const [key, val] of Object.entries(headers)) {
573
+ if (key === "host" || key === "connection" || key === "content-length") continue;
574
+ const displayVal = isSecretHeader(key) ? "REDACTED" : String(val);
575
+ const safe = displayVal.replace(/'/g, "'\\''");
576
+ cmd += ` \\\n -H '${key}: ${safe}'`;
577
+ }
578
+
579
+ if (req.body && req.method !== "GET" && req.method !== "HEAD") {
580
+ const body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
581
+ const safe = body.replace(/'/g, "'\\''");
582
+ cmd += ` \\\n -d '${safe}'`;
583
+ }
584
+
585
+ return cmd;
586
+ }
587
+
588
+ function maskSecrets(envVars) {
589
+ const secretPatterns = /secret|password|token|key|auth|credential|private|jwt|session/i;
590
+ const result = {};
591
+
592
+ for (const [key, val] of Object.entries(envVars)) {
593
+ if (secretPatterns.test(key)) {
594
+ result[key] = "••••••••";
595
+ }
596
+ else {
597
+ result[key] = val;
598
+ }
599
+ }
600
+
601
+ return result;
602
+ }
603
+
604
+ function isSecretHeader(name) {
605
+ return /authorization|cookie|token|secret|session/i.test(name);
606
+ }
607
+
608
+ function shortenPath(filePath) {
609
+ if (!filePath) return "";
610
+
611
+ const normalized = filePath.replace(/\\/g, "/");
612
+ const appMatch = normalized.match(/(apps?\/[^/]+\/.*)/);
613
+ if (appMatch) return appMatch[1];
614
+
615
+ const libMatch = normalized.match(/(packages\/nitronjs\/.*)/);
616
+ if (libMatch) return libMatch[1];
617
+
618
+ const parts = normalized.split("/");
619
+ if (parts.length > 4) {
620
+ return ".../" + parts.slice(-3).join("/");
621
+ }
622
+
623
+ return normalized;
624
+ }
625
+
626
+ function generateStyles(nonceAttr) {
627
+ return `<style${nonceAttr}>
628
+ :root[data-theme="dark"] {
629
+ --bg: #0a0a0a; --surface: #141414; --surface2: #1c1c1c; --surface3: #242424;
630
+ --border: #1e1e1e; --border2: #2a2a2a;
631
+ --text: #f0f0f0; --text2: #888888; --text3: #555555; --text4: #3a3a3a;
632
+ --accent: #3b82f6; --red: #ef4444; --yellow: #fbbf24;
633
+ --seg-mw: #3b82f6; --seg-ctrl: #f0f0f0; --seg-render: #888888; --seg-other: #2a2a2a;
634
+ }
635
+ :root[data-theme="light"] {
636
+ --bg: #ffffff; --surface: #f8f8f8; --surface2: #f0f0f0; --surface3: #e8e8e8;
637
+ --border: #e0e0e0; --border2: #d0d0d0;
638
+ --text: #111111; --text2: #555555; --text3: #888888; --text4: #bbbbbb;
639
+ --accent: #2563eb; --red: #dc2626; --yellow: #d97706;
640
+ --seg-mw: #2563eb; --seg-ctrl: #111111; --seg-render: #888888; --seg-other: #e0e0e0;
641
+ }
642
+ * { box-sizing: border-box; margin: 0; padding: 0; }
643
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
644
+ .mono { font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace; }
645
+ .error-page { position: relative; max-width: 1100px; margin: 0 auto; padding: 0 32px; min-height: 100vh; display: flex; flex-direction: column; overflow: hidden; animation: fadeIn 0.3s ease-out; }
646
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
647
+
648
+ /* Watermark */
649
+ .ep-watermark { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 420px; height: 420px; opacity: 0.035; pointer-events: none; user-select: none; filter: brightness(0) invert(1); z-index: 0; }
650
+ :root[data-theme="light"] .ep-watermark { filter: brightness(0); }
651
+
652
+ /* Hero Section */
653
+ .ep-hero { position: relative; z-index: 1; padding: 48px 0 28px; text-align: center; }
654
+ .ep-hero-icon { margin-bottom: 20px; }
655
+ .ep-hero-icon img { filter: brightness(0) invert(1); opacity: 0.5; }
656
+ :root[data-theme="light"] .ep-hero-icon img { filter: brightness(0); opacity: 0.4; }
657
+ .ep-error-type { font-size: 14px; font-weight: 700; color: var(--text); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 10px; opacity: 0.5; }
658
+ .ep-error-code { font-size: 12px; color: var(--text3); font-weight: 500; letter-spacing: 0; text-transform: none; background: var(--surface2); padding: 2px 8px; border-radius: 4px; margin-left: 8px; }
659
+ .ep-error-message { font-size: 26px; font-weight: 600; color: var(--text); line-height: 1.35; margin-bottom: 14px; word-break: break-word; max-width: 800px; margin-left: auto; margin-right: auto; }
660
+ .ep-error-location { display: inline-flex; align-items: center; gap: 12px; margin-bottom: 20px; }
661
+ .ep-file { font-family: 'SF Mono', Consolas, monospace; font-size: 13px; color: var(--text2); }
662
+ .ep-editor-link { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--accent); text-decoration: none; padding: 3px 10px; background: rgba(59,130,246,0.08); border-radius: 4px; transition: all 0.15s; }
663
+ .ep-editor-link:hover { background: rgba(59,130,246,0.15); }
664
+ .ep-editor-link-sm { font-size: 11px; color: var(--accent); text-decoration: none; opacity: 0.7; transition: opacity 0.15s; }
665
+ .ep-editor-link-sm:hover { opacity: 1; }
666
+
667
+ /* Code Block */
668
+ .ep-code-block { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin: 16px auto 0; max-width: 800px; text-align: left; }
669
+ .ep-code-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 14px; background: var(--surface); font-size: 12px; color: var(--text3); font-family: 'SF Mono', Consolas, monospace; border-bottom: 1px solid var(--border); }
670
+ .ep-code-body { overflow-x: auto; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; background: var(--bg); }
671
+ .ep-code-line { display: flex; padding: 0 14px; }
672
+ .ep-code-line.error-line { background: rgba(239,68,68,0.08); }
673
+ .ep-line-num { width: 50px; flex-shrink: 0; text-align: right; padding-right: 16px; color: var(--text4); user-select: none; }
674
+ .ep-code-line.error-line .ep-line-num { color: var(--red); font-weight: 600; }
675
+ .ep-line-code { white-space: pre; }
676
+
677
+ /* Timing Bar */
678
+ .ep-timing-bar { max-width: 600px; margin: 20px auto 0; }
679
+ .ep-timing-segments { display: flex; height: 4px; border-radius: 2px; overflow: hidden; background: var(--surface2); margin-bottom: 8px; }
680
+ .ep-timing-seg { height: 100%; }
681
+ .seg-mw { background: var(--seg-mw); }
682
+ .seg-ctrl { background: var(--seg-ctrl); }
683
+ .seg-render { background: var(--seg-render); }
684
+ .seg-other { background: var(--seg-other); }
685
+ .ep-timing-labels { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
686
+ .ep-timing-label { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text3); }
687
+ .ep-dot { width: 6px; height: 6px; border-radius: 50%; }
688
+ .ep-dot.seg-mw { background: var(--seg-mw); }
689
+ .ep-dot.seg-ctrl { background: var(--seg-ctrl); }
690
+ .ep-dot.seg-render { background: var(--seg-render); }
691
+
692
+ /* Buttons */
693
+ .ep-btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text3); cursor: pointer; font-size: 11px; transition: all 0.15s; }
694
+ .ep-btn-sm:hover { background: var(--surface2); color: var(--text); border-color: var(--border2); }
695
+
696
+ /* Tabs */
697
+ .ep-tabs { position: relative; z-index: 1; display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-top: 8px; }
698
+ .ep-tab { padding: 10px 18px; background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--text3); cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; display: flex; align-items: center; gap: 6px; }
699
+ .ep-tab:hover { color: var(--text2); background: var(--surface); }
700
+ .ep-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
701
+ .ep-tab-key { font-size: 10px; padding: 1px 5px; background: var(--surface2); border-radius: 3px; color: var(--text4); font-weight: 600; }
702
+ .ep-tab.active .ep-tab-key { background: rgba(59,130,246,0.15); color: var(--accent); }
703
+
704
+ /* Tab Content */
705
+ .ep-content { position: relative; z-index: 1; flex: 1; padding: 20px 0; }
706
+ .ep-tab-panel { display: none; }
707
+ .ep-tab-panel.active { display: block; }
708
+
709
+ /* Sections */
710
+ .ep-section { margin-bottom: 20px; }
711
+ .ep-section-title { font-size: 11px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
712
+ .ep-section-title.collapsible { cursor: pointer; display: flex; align-items: center; justify-content: space-between; }
713
+ .ep-section-title.collapsible::after { content: "▸"; transition: transform 0.15s; }
714
+ .ep-section-title.collapsible.open::after { transform: rotate(90deg); }
715
+ .ep-collapsible { max-height: 0; overflow: hidden; transition: max-height 0.2s ease-out; }
716
+ .ep-collapsible.open { max-height: 2000px; }
717
+
718
+ /* Stack Trace */
719
+ .ep-stack-controls { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
720
+ .ep-search-box { display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; flex: 1; min-width: 200px; color: var(--text3); }
721
+ .ep-search-input { background: transparent; border: none; outline: none; color: var(--text); font-size: 13px; width: 100%; }
722
+ .ep-search-input::placeholder { color: var(--text4); }
723
+ .ep-stack-toggles { display: flex; align-items: center; gap: 12px; }
724
+ .ep-toggle { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text3); cursor: pointer; }
725
+ .ep-toggle input { accent-color: var(--accent); }
726
+ .ep-count { font-size: 10px; padding: 1px 5px; background: var(--surface2); border-radius: 3px; color: var(--text4); }
727
+ .ep-count-label { font-size: 12px; color: var(--text3); }
728
+ .ep-frames { display: flex; flex-direction: column; gap: 1px; }
729
+ .ep-frame { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: var(--surface); border-radius: 4px; transition: all 0.1s; }
730
+ .ep-frame:hover { background: var(--surface2); }
731
+ .ep-frame.frame-hidden { display: none; }
732
+ .ep-frame-fn { font-family: 'SF Mono', Consolas, monospace; font-size: 13px; font-weight: 500; }
733
+ .ep-frame-loc { display: flex; align-items: center; gap: 10px; }
734
+ .ep-frame-file { font-family: 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--text3); }
735
+ .frame-app .ep-frame-fn { color: var(--text); }
736
+ .frame-app .ep-frame-file { color: var(--text2); }
737
+ .frame-framework .ep-frame-fn { color: var(--text3); }
738
+ .frame-framework .ep-frame-file { color: var(--text4); }
739
+ .frame-vendor .ep-frame-fn { color: var(--text4); }
740
+ .frame-vendor .ep-frame-file { color: var(--text4); }
741
+ .frame-internal .ep-frame-fn { color: var(--text4); }
742
+
743
+ /* KV Pairs */
744
+ .ep-kv-group { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
745
+ .ep-kv { display: flex; align-items: baseline; gap: 12px; padding: 4px 0; }
746
+ .ep-key { font-size: 12px; color: var(--text3); min-width: 100px; flex-shrink: 0; }
747
+ .ep-val { font-size: 13px; color: var(--text2); word-break: break-all; }
748
+
749
+ /* Request Tab */
750
+ .ep-request-method { font-size: 18px; font-weight: 700; color: var(--accent); margin-bottom: 12px; }
751
+ .ep-request-url { font-weight: 400; color: var(--text); font-family: 'SF Mono', Consolas, monospace; font-size: 16px; }
752
+ .ep-table { width: 100%; border-collapse: collapse; }
753
+ .ep-table tr { border-bottom: 1px solid var(--border); }
754
+ .ep-table tr:last-child { border-bottom: none; }
755
+ .ep-td-key { padding: 6px 12px 6px 0; font-family: 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--text3); width: 200px; vertical-align: top; }
756
+ .ep-td-val { padding: 6px 0; font-size: 12px; color: var(--text2); word-break: break-all; }
757
+ .ep-pre { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 12px; font-family: 'SF Mono', Consolas, monospace; font-size: 12px; overflow-x: auto; white-space: pre-wrap; color: var(--text2); }
758
+ .ep-curl-box { position: relative; }
759
+ .ep-copy-curl { position: absolute; top: 8px; right: 8px; }
760
+ .ep-curl { padding-right: 70px; }
761
+
762
+ /* Route Tab */
763
+ .ep-method-badge { padding: 2px 8px; background: rgba(59,130,246,0.1); color: var(--accent); border-radius: 4px; font-size: 12px; font-weight: 600; font-family: 'SF Mono', Consolas, monospace; }
764
+ .ep-mw-list { display: flex; flex-direction: column; gap: 4px; }
765
+ .ep-mw-item { display: flex; align-items: center; gap: 10px; padding: 6px 10px; background: var(--surface); border-radius: 4px; }
766
+ .ep-mw-index { font-size: 10px; padding: 2px 6px; background: var(--surface2); border-radius: 3px; color: var(--text4); font-weight: 600; }
767
+ .ep-mw-name { font-family: 'SF Mono', Consolas, monospace; font-size: 13px; color: var(--text2); }
768
+ .ep-mw-time { font-size: 11px; color: var(--text3); margin-left: auto; }
769
+
770
+ /* Props Tab */
771
+ .ep-props-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
772
+ .ep-props-json { max-height: 400px; overflow-y: auto; }
773
+ .ep-props-filtered { margin-bottom: 16px; padding: 12px; background: rgba(239,68,68,0.05); border: 1px solid rgba(239,68,68,0.15); border-radius: 8px; }
774
+ .ep-filtered-list { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
775
+ .ep-filtered-key { padding: 3px 10px; background: rgba(239,68,68,0.1); color: var(--red); font-size: 12px; border-radius: 4px; font-family: 'SF Mono', Consolas, monospace; }
776
+
777
+ /* Empty */
778
+ .ep-empty { padding: 40px; text-align: center; color: var(--text4); font-size: 14px; }
779
+ .ep-empty-sm { padding: 8px; color: var(--text4); font-size: 12px; }
780
+
781
+ /* Footer */
782
+ .ep-footer { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-top: 1px solid var(--border); margin-top: auto; font-size: 12px; color: var(--text4); }
783
+ .ep-footer-info { display: flex; align-items: center; gap: 8px; }
784
+ .ep-footer-actions { display: flex; align-items: center; gap: 6px; }
785
+ .ep-divider { color: var(--border2); }
786
+ .ep-error-count { color: var(--yellow); }
787
+
788
+ /* Shortcuts Overlay */
789
+ .ep-shortcuts-overlay { display:none;position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);align-items:center;justify-content:center; }
790
+ .ep-shortcuts-overlay.open { display:flex; }
791
+ .ep-shortcuts-panel { background:var(--surface);border:1px solid var(--border2);border-radius:12px;padding:24px 28px;min-width:340px;animation:fadeIn 0.15s ease-out; }
792
+ .ep-shortcuts-title { font-size:13px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid var(--border); }
793
+ .ep-shortcuts-grid { display:grid;grid-template-columns:1fr 1fr;gap:8px 24px; }
794
+ .ep-shortcut { display:flex;align-items:center;gap:10px;padding:4px 0; }
795
+ .ep-shortcut kbd { display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:24px;padding:0 6px;background:var(--bg);border:1px solid var(--border2);border-radius:5px;font-family:'SF Mono',Consolas,monospace;font-size:11px;font-weight:600;color:var(--text2); }
796
+ .ep-shortcut span { font-size:12px;color:var(--text3); }
797
+
798
+ /* Responsive */
799
+ @media (max-width: 768px) {
800
+ .error-page { padding: 0 16px; }
801
+ .ep-hero { padding: 32px 0 20px; }
802
+ .ep-error-message { font-size: 20px; }
803
+ .ep-watermark { width: 280px; height: 280px; }
804
+ .ep-props-columns { grid-template-columns: 1fr; }
805
+ .ep-stack-controls { flex-direction: column; align-items: stretch; }
806
+ .ep-tabs { overflow-x: auto; }
807
+ }
808
+
809
+ /* Scrollbar */
810
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
811
+ ::-webkit-scrollbar-track { background: transparent; }
812
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
813
+ ::-webkit-scrollbar-thumb:hover { background: var(--text4); }
814
+ </style>`;
815
+ }
816
+
817
+ function generateScript(nonceAttr, contextJson) {
818
+ return `<script${nonceAttr}>
819
+ (function(){
820
+ var ctx = ${contextJson};
821
+
822
+ // Console mirror
823
+ console.error(
824
+ "%c " + ctx.error.name + ": " + ctx.error.message + " ",
825
+ "background:#ef4444;color:#fff;padding:4px 8px;border-radius:4px;font-weight:bold;"
826
+ );
827
+ if (ctx.error.stack) console.error(ctx.error.stack);
828
+
829
+ // Tab switching
830
+ var tabs = document.querySelectorAll('.ep-tab');
831
+ var panels = document.querySelectorAll('.ep-tab-panel');
832
+ function switchTab(name) {
833
+ tabs.forEach(function(t) { t.classList.toggle('active', t.dataset.tab === name); });
834
+ panels.forEach(function(p) { p.classList.toggle('active', p.dataset.panel === name); });
835
+ }
836
+ tabs.forEach(function(t) {
837
+ t.addEventListener('click', function() { switchTab(t.dataset.tab); });
838
+ });
839
+
840
+ // Keyboard shortcuts
841
+ document.addEventListener('keydown', function(e) {
842
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
843
+ var tabNames = ['stacktrace', 'request', 'route', 'props'];
844
+ if (e.key >= '1' && e.key <= '4') {
845
+ e.preventDefault();
846
+ switchTab(tabNames[parseInt(e.key) - 1]);
847
+ }
848
+ if (e.key === 'c' || e.key === 'C') {
849
+ e.preventDefault();
850
+ navigator.clipboard.writeText(ctx.error.stack || '').then(function() {
851
+ showToast('Stack trace copied');
852
+ });
853
+ }
854
+ if (e.key === 's' || e.key === 'S') {
855
+ e.preventDefault();
856
+ switchTab('stacktrace');
857
+ var input = document.getElementById('stackSearch');
858
+ if (input) input.focus();
859
+ }
860
+ if (e.key === 'f' || e.key === 'F') {
861
+ e.preventDefault();
862
+ var cb = document.getElementById('showFramework');
863
+ if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
864
+ }
865
+ if (e.key === 'd' || e.key === 'D') {
866
+ e.preventDefault();
867
+ toggleTheme();
868
+ }
869
+ if (e.key === '?' || e.key === '/') {
870
+ e.preventDefault();
871
+ toggleShortcuts();
872
+ }
873
+ if (e.key === 'Escape') {
874
+ var overlay = document.getElementById('shortcutsOverlay');
875
+ if (overlay.classList.contains('open')) {
876
+ overlay.classList.remove('open');
877
+ return;
878
+ }
879
+ var input = document.getElementById('stackSearch');
880
+ if (input && document.activeElement === input) {
881
+ input.value = '';
882
+ input.blur();
883
+ filterFrames('');
884
+ }
885
+ }
886
+ });
887
+
888
+ // Theme toggle
889
+ var stored = localStorage.getItem('__nitron_theme__');
890
+ if (stored) document.documentElement.setAttribute('data-theme', stored);
891
+
892
+ function toggleTheme() {
893
+ var current = document.documentElement.getAttribute('data-theme');
894
+ var next = current === 'dark' ? 'light' : 'dark';
895
+ document.documentElement.setAttribute('data-theme', next);
896
+ localStorage.setItem('__nitron_theme__', next);
897
+ }
898
+ document.getElementById('themeToggle').addEventListener('click', toggleTheme);
899
+
900
+ // Shortcuts overlay
901
+ var shortcutsOverlay = document.getElementById('shortcutsOverlay');
902
+ function toggleShortcuts() {
903
+ shortcutsOverlay.classList.toggle('open');
904
+ }
905
+ document.getElementById('shortcutsToggle').addEventListener('click', toggleShortcuts);
906
+ shortcutsOverlay.addEventListener('click', function(e) {
907
+ if (e.target === shortcutsOverlay) shortcutsOverlay.classList.remove('open');
908
+ });
909
+
910
+ // JSON Export
911
+ document.getElementById('jsonExport').addEventListener('click', function() {
912
+ var blob = new Blob([JSON.stringify(ctx, null, 2)], { type: 'application/json' });
913
+ var url = URL.createObjectURL(blob);
914
+ var a = document.createElement('a');
915
+ a.href = url;
916
+ a.download = 'nitron-error-' + Date.now() + '.json';
917
+ a.click();
918
+ URL.revokeObjectURL(url);
919
+ showToast('JSON exported');
920
+ });
921
+
922
+ // Stack trace search
923
+ var searchInput = document.getElementById('stackSearch');
924
+ if (searchInput) {
925
+ searchInput.addEventListener('input', function() { filterFrames(this.value); });
926
+ }
927
+
928
+ function filterFrames(query) {
929
+ var frames = document.querySelectorAll('.ep-frame');
930
+ var q = query.toLowerCase();
931
+ frames.forEach(function(f) {
932
+ var text = f.textContent.toLowerCase();
933
+ var matchesSearch = !q || text.includes(q);
934
+ var type = f.dataset.type;
935
+ var showFw = document.getElementById('showFramework')?.checked;
936
+ var showVnd = document.getElementById('showVendor')?.checked;
937
+ var showByType = type === 'app' || (type === 'framework' && showFw) || (type === 'vendor' && showVnd) || type === 'internal';
938
+ f.classList.toggle('frame-hidden', !(matchesSearch && showByType));
939
+ });
940
+ }
941
+
942
+ // Framework/Vendor toggles
943
+ var fwCb = document.getElementById('showFramework');
944
+ var vndCb = document.getElementById('showVendor');
945
+ if (fwCb) fwCb.addEventListener('change', function() { filterFrames(searchInput?.value || ''); });
946
+ if (vndCb) vndCb.addEventListener('change', function() { filterFrames(searchInput?.value || ''); });
947
+
948
+ // Initialize framework frames visibility
949
+ filterFrames('');
950
+
951
+ // Collapsible sections
952
+ document.querySelectorAll('.ep-section-title.collapsible').forEach(function(title) {
953
+ title.addEventListener('click', function() {
954
+ var target = document.getElementById(this.dataset.collapse);
955
+ if (target) {
956
+ this.classList.toggle('open');
957
+ target.classList.toggle('open');
958
+ }
959
+ });
960
+ });
961
+
962
+ // Copy buttons
963
+ document.querySelectorAll('[data-copy]').forEach(function(btn) {
964
+ btn.addEventListener('click', function() {
965
+ navigator.clipboard.writeText(this.dataset.copy).then(function() {
966
+ showToast('Copied to clipboard');
967
+ });
968
+ });
969
+ });
970
+
971
+ document.getElementById('copyStack')?.addEventListener('click', function() {
972
+ navigator.clipboard.writeText(ctx.error.stack || '').then(function() {
973
+ showToast('Stack trace copied');
974
+ });
975
+ });
976
+
977
+ // Toast notification
978
+ function showToast(msg) {
979
+ var existing = document.querySelector('.ep-toast');
980
+ if (existing) existing.remove();
981
+ var toast = document.createElement('div');
982
+ toast.className = 'ep-toast';
983
+ toast.textContent = msg;
984
+ toast.style.cssText = 'position:fixed;bottom:24px;right:24px;padding:10px 20px;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:13px;z-index:999999;animation:fadeIn 0.15s ease-out;';
985
+ document.body.appendChild(toast);
986
+ setTimeout(function() { toast.remove(); }, 2000);
987
+ }
988
+ })();
989
+ </script>`;
990
+ }