webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
package/src/helpers.js CHANGED
@@ -27,11 +27,28 @@ const isPathRegexCache = new Map();
27
27
  class AssetManager {
28
28
  constructor(options = {}) {
29
29
  this.publicDir = options.publicDir || 'public';
30
+ this.srcDir = options.srcDir || 'src';
31
+ this.pagesDir = options.pagesDir || 'pages';
30
32
  this.manifestPath = options.manifestPath || null;
31
33
  this.manifest = null;
32
34
  this.version = options.version || null;
33
35
  this.prefix = options.prefix || '';
34
36
 
37
+ // Auto-detect manifest if not explicitly provided
38
+ if (!this.manifestPath) {
39
+ const candidates = [
40
+ path.join(this.publicDir, 'dist', 'manifest.json'),
41
+ path.join(this.publicDir, 'manifest.json'),
42
+ path.join(this.publicDir, '.vite', 'manifest.json'),
43
+ ];
44
+ for (const candidate of candidates) {
45
+ if (fs.existsSync(candidate)) {
46
+ this.manifestPath = candidate;
47
+ break;
48
+ }
49
+ }
50
+ }
51
+
35
52
  // Load manifest if path provided
36
53
  if (this.manifestPath) {
37
54
  this.loadManifest();
@@ -65,16 +82,35 @@ class AssetManager {
65
82
 
66
83
  // Check manifest first
67
84
  if (this.manifest) {
68
- // Vite manifest format
69
- if (this.manifest[lookupPath]) {
70
- const entry = this.manifest[lookupPath];
71
- const resolved = typeof entry === 'string' ? entry : entry.file;
72
- return this.prefix + '/' + resolved;
85
+ // 1. Direct match with lookupPath or assetPath
86
+ let entry = this.manifest[lookupPath] || this.manifest[assetPath];
87
+
88
+ // 2. Flexible fallback: match by basename or trailing path
89
+ if (!entry) {
90
+ const baseName = path.basename(lookupPath);
91
+ for (const [key, val] of Object.entries(this.manifest)) {
92
+ if (
93
+ key === baseName ||
94
+ path.basename(key) === baseName ||
95
+ key.endsWith('/' + lookupPath) ||
96
+ lookupPath.endsWith('/' + key)
97
+ ) {
98
+ entry = val;
99
+ break;
100
+ }
101
+ }
73
102
  }
74
-
75
- // Webpack manifest format (direct mapping)
76
- if (this.manifest[assetPath]) {
77
- return this.prefix + this.manifest[assetPath];
103
+
104
+ if (entry) {
105
+ // Webpack string format (e.g. "/dist/app.xyz789.js")
106
+ if (typeof entry === 'string') {
107
+ return this.prefix + entry;
108
+ }
109
+ // Vite / esbuild metafile format (e.g. { file: "assets/app-xyz789.js" })
110
+ if (entry.file) {
111
+ const file = entry.file.startsWith('/') ? entry.file : '/' + entry.file;
112
+ return this.prefix ? (this.prefix + file).replace(/\/+/g, '/') : file;
113
+ }
78
114
  }
79
115
  }
80
116
 
@@ -135,6 +171,332 @@ class AssetManager {
135
171
  return `<img ${attrs}>`;
136
172
  }
137
173
 
174
+ /**
175
+ * Resolve page-specific JS script URL
176
+ * @param {string} name - Page name (e.g. 'home', 'checkout', 'pages/home.js')
177
+ * @returns {string} Resolved script URL
178
+ */
179
+ pageJsUrl(name) {
180
+ let clean = String(name || '').trim().replace(/^\/+/, '');
181
+ if (!clean.endsWith('.js') && !clean.endsWith('.mjs')) {
182
+ clean += '.js';
183
+ }
184
+ let pagePath = clean;
185
+ if (!pagePath.startsWith('pages/') && !pagePath.startsWith('dist/')) {
186
+ pagePath = 'pages/' + pagePath;
187
+ }
188
+ if (this.manifest) {
189
+ if (this.manifest[pagePath] || this.manifest['js/' + pagePath]) {
190
+ return this.resolve(pagePath);
191
+ }
192
+ }
193
+ const devPath = pagePath.startsWith('js/') ? pagePath : 'js/' + pagePath;
194
+ return this.resolve(devPath);
195
+ }
196
+
197
+ /**
198
+ * Generate page-specific JS script tag
199
+ * Resolves pages/<name>.js with defer attribute by default
200
+ * @param {string} name - Page name (e.g. 'home', 'checkout', 'pages/checkout.js')
201
+ * @param {Object} [attributes={}] - Additional HTML attributes (e.g. { type: 'module' })
202
+ * @returns {string} Script tag
203
+ */
204
+ pageJs(name, attributes = {}) {
205
+ const resolvedUrl = this.pageJsUrl(name);
206
+ const attrs = this.buildAttributes({
207
+ src: resolvedUrl,
208
+ defer: true,
209
+ ...attributes,
210
+ });
211
+ return `<script ${attrs}></script>`;
212
+ }
213
+
214
+ /**
215
+ * Resolve page-specific CSS stylesheet URL
216
+ * @param {string} name - Page name (e.g. 'home', 'checkout', 'pages/home.css')
217
+ * @returns {string} Resolved stylesheet URL
218
+ */
219
+ pageCssUrl(name) {
220
+ let clean = String(name || '').trim().replace(/^\/+/, '');
221
+ if (!clean.endsWith('.css')) {
222
+ clean += '.css';
223
+ }
224
+ let pagePath = clean;
225
+ if (!pagePath.startsWith('pages/') && !pagePath.startsWith('dist/')) {
226
+ pagePath = 'pages/' + pagePath;
227
+ }
228
+ if (this.manifest) {
229
+ if (this.manifest[pagePath] || this.manifest['css/' + pagePath]) {
230
+ return this.resolve(pagePath);
231
+ }
232
+ }
233
+ const devPath = pagePath.startsWith('css/') ? pagePath : 'css/' + pagePath;
234
+ return this.resolve(devPath);
235
+ }
236
+
237
+ /**
238
+ * Generate page-specific CSS link tag
239
+ * Resolves pages/<name>.css
240
+ * @param {string} name - Page name (e.g. 'home', 'checkout', 'pages/checkout.css')
241
+ * @param {Object} [attributes={}] - Additional HTML attributes
242
+ * @returns {string} Link tag
243
+ */
244
+ pageCss(name, attributes = {}) {
245
+ const resolvedUrl = this.pageCssUrl(name);
246
+ const attrs = this.buildAttributes({
247
+ rel: 'stylesheet',
248
+ href: resolvedUrl,
249
+ ...attributes,
250
+ });
251
+ return `<link ${attrs}>`;
252
+ }
253
+
254
+ /**
255
+ * Derive candidate page asset names from route file and path
256
+ * @param {string} [routeFile] - e.g. 'checkout.njk', 'pages/products/[id].njk'
257
+ * @param {string} [routePath] - e.g. '/checkout', '/products/:id', '/'
258
+ * @returns {string[]} Ordered candidate asset names
259
+ */
260
+ getRouteAssetCandidates(routeFile, routePath) {
261
+ const candidates = new Set();
262
+
263
+ if (routeFile) {
264
+ let cleanFile = String(routeFile).trim().replace(/^\/+/, '');
265
+ if (cleanFile.startsWith('pages/')) {
266
+ cleanFile = cleanFile.slice(6);
267
+ }
268
+ cleanFile = cleanFile.replace(/\.(njk|html|ejs|hbs|js)$/, '');
269
+ if (cleanFile) {
270
+ candidates.add(cleanFile);
271
+ if (cleanFile.endsWith('/index')) {
272
+ candidates.add(cleanFile.slice(0, -6));
273
+ }
274
+ if (cleanFile.includes('[')) {
275
+ const parts = cleanFile.split('/');
276
+ const staticParts = parts.filter(p => !p.startsWith('['));
277
+ if (staticParts.length > 0) {
278
+ candidates.add(staticParts.join('/'));
279
+ }
280
+ }
281
+ if (cleanFile.includes('(')) {
282
+ const withoutGroups = cleanFile.split('/').filter(p => !(p.startsWith('(') && p.endsWith(')'))).join('/');
283
+ if (withoutGroups) {
284
+ candidates.add(withoutGroups);
285
+ if (withoutGroups.endsWith('/index')) {
286
+ candidates.add(withoutGroups.slice(0, -6));
287
+ }
288
+ }
289
+ }
290
+ if (cleanFile === 'index') {
291
+ candidates.add('home');
292
+ }
293
+ }
294
+ }
295
+
296
+ if (routePath) {
297
+ let cleanPath = String(routePath).trim().replace(/^\/+/, '').replace(/\/+$/, '');
298
+ if (cleanPath) {
299
+ candidates.add(cleanPath);
300
+ if (cleanPath.includes(':')) {
301
+ const bracketed = cleanPath.split('/').map(seg => seg.startsWith(':') ? `[${seg.slice(1)}]` : seg).join('/');
302
+ candidates.add(bracketed);
303
+ const withoutParams = cleanPath.split('/').filter(seg => !seg.startsWith(':')).join('/');
304
+ if (withoutParams) candidates.add(withoutParams);
305
+ }
306
+ } else {
307
+ candidates.add('index');
308
+ candidates.add('home');
309
+ }
310
+ }
311
+
312
+ return Array.from(candidates);
313
+ }
314
+
315
+ /**
316
+ * Check if a page-specific JS script exists (in manifest or on filesystem)
317
+ * @param {string} name - Page name
318
+ * @returns {boolean}
319
+ */
320
+ hasPageJs(name) {
321
+ if (!name || typeof name !== 'string') return false;
322
+ let clean = String(name).trim().replace(/^\/+/, '');
323
+ if (clean.startsWith('pages/')) clean = clean.slice(6);
324
+ if (clean.startsWith('dist/pages/')) clean = clean.slice(11);
325
+ if (clean.startsWith('js/pages/')) clean = clean.slice(9);
326
+ const base = clean.replace(/\.(js|mjs|ts)$/, '');
327
+ const pagePath = 'pages/' + base + '.js';
328
+
329
+ // 1. Check in manifest (production or dev manifest)
330
+ if (this.manifest) {
331
+ if (
332
+ this.manifest[pagePath] ||
333
+ this.manifest['js/' + pagePath] ||
334
+ this.manifest['pages/' + base] ||
335
+ this.manifest[base + '.js'] ||
336
+ this.manifest[base] ||
337
+ this.manifest['src/client/' + pagePath] ||
338
+ this.manifest['client/' + pagePath]
339
+ ) {
340
+ return true;
341
+ }
342
+ }
343
+
344
+ // 2. Check filesystem
345
+ const publicDir = this.publicDir || 'public';
346
+ const srcDir = this.srcDir || 'src';
347
+ const pagesDir = this.pagesDir || 'pages';
348
+
349
+ const diskChecks = [
350
+ path.join(publicDir, 'js', pagePath),
351
+ path.join(publicDir, pagePath),
352
+ path.join(publicDir, 'dist', pagePath),
353
+ path.join(srcDir, 'client', pagePath),
354
+ path.join(srcDir, 'client', 'pages', base + '.ts'),
355
+ path.join('src', 'client', 'pages', base + '.js'),
356
+ path.join('src', 'client', 'pages', base + '.ts'),
357
+ path.join('client', 'pages', base + '.js'),
358
+ path.join('client', 'pages', base + '.ts'),
359
+ path.join(pagesDir, base + '.client.js'),
360
+ path.join(pagesDir, base + '.client.ts'),
361
+ path.join('pages', base + '.client.js'),
362
+ path.join('pages', base + '.client.ts'),
363
+ ];
364
+
365
+ for (const p of diskChecks) {
366
+ try {
367
+ if (fs.existsSync(p)) {
368
+ return true;
369
+ }
370
+ } catch (_) {}
371
+ }
372
+
373
+ return false;
374
+ }
375
+
376
+ /**
377
+ * Check if a page-specific CSS stylesheet exists (in manifest or on filesystem)
378
+ * @param {string} name - Page name
379
+ * @returns {boolean}
380
+ */
381
+ hasPageCss(name) {
382
+ if (!name || typeof name !== 'string') return false;
383
+ let clean = String(name).trim().replace(/^\/+/, '');
384
+ if (clean.startsWith('pages/')) clean = clean.slice(6);
385
+ if (clean.startsWith('dist/pages/')) clean = clean.slice(11);
386
+ if (clean.startsWith('css/pages/')) clean = clean.slice(10);
387
+ const base = clean.replace(/\.css$/, '');
388
+ const pagePath = 'pages/' + base + '.css';
389
+
390
+ // 1. Check in manifest
391
+ if (this.manifest) {
392
+ if (
393
+ this.manifest[pagePath] ||
394
+ this.manifest['css/' + pagePath] ||
395
+ this.manifest['pages/' + base] ||
396
+ this.manifest[base + '.css'] ||
397
+ this.manifest[base] ||
398
+ this.manifest['src/client/' + pagePath] ||
399
+ this.manifest['client/' + pagePath]
400
+ ) {
401
+ return true;
402
+ }
403
+ }
404
+
405
+ // 2. Check filesystem
406
+ const publicDir = this.publicDir || 'public';
407
+ const srcDir = this.srcDir || 'src';
408
+ const pagesDir = this.pagesDir || 'pages';
409
+
410
+ const diskChecks = [
411
+ path.join(publicDir, 'css', pagePath),
412
+ path.join(publicDir, pagePath),
413
+ path.join(publicDir, 'dist', pagePath),
414
+ path.join(srcDir, 'client', pagePath),
415
+ path.join('src', 'client', 'pages', base + '.css'),
416
+ path.join('client', 'pages', base + '.css'),
417
+ path.join(pagesDir, base + '.css'),
418
+ path.join('pages', base + '.css'),
419
+ path.join('public', 'css', 'pages', base + '.css'),
420
+ ];
421
+
422
+ for (const p of diskChecks) {
423
+ try {
424
+ if (fs.existsSync(p)) {
425
+ return true;
426
+ }
427
+ } catch (_) {}
428
+ }
429
+
430
+ return false;
431
+ }
432
+
433
+ /**
434
+ * Generate combined page asset tags (link and/or script) for a given page name
435
+ * @param {string|Object} [nameOrOptions] - Page name or options
436
+ * @param {Object} [options={}] - Options ({ js?: boolean, css?: boolean, cssAttrs?: Object, ...jsAttrs })
437
+ * @returns {string} HTML tags joined with newline
438
+ */
439
+ pageAssets(nameOrOptions, options = {}) {
440
+ let name = nameOrOptions;
441
+ let opts = options;
442
+ if (typeof nameOrOptions === 'object' && nameOrOptions !== null) {
443
+ opts = nameOrOptions;
444
+ name = null;
445
+ }
446
+
447
+ if (!name) return '';
448
+
449
+ const tags = [];
450
+ const includeCss = opts.css !== false;
451
+ const includeJs = opts.js !== false;
452
+
453
+ if (includeCss && this.hasPageCss(name)) {
454
+ tags.push(this.pageCss(name, opts.cssAttrs || {}));
455
+ }
456
+
457
+ if (includeJs && this.hasPageJs(name)) {
458
+ const { css: _c, js: _j, cssAttrs: _ca, ...jsAttrs } = opts;
459
+ tags.push(this.pageJs(name, jsAttrs));
460
+ }
461
+
462
+ return tags.join('\n');
463
+ }
464
+
465
+ /**
466
+ * Generate combined page asset tags automatically matching a route
467
+ * @param {string} [routeFile] - Route template file
468
+ * @param {string} [routePath] - Route path
469
+ * @param {Object} [options={}] - Options ({ js?: boolean, css?: boolean, cssAttrs?: Object, ...jsAttrs })
470
+ * @returns {string} HTML tags joined with newline
471
+ */
472
+ pageAssetsForRoute(routeFile, routePath, options = {}) {
473
+ const candidates = this.getRouteAssetCandidates(routeFile, routePath);
474
+ const tags = [];
475
+ const includeCss = options.css !== false;
476
+ const includeJs = options.js !== false;
477
+
478
+ if (includeCss) {
479
+ for (const cand of candidates) {
480
+ if (this.hasPageCss(cand)) {
481
+ tags.push(this.pageCss(cand, options.cssAttrs || {}));
482
+ break;
483
+ }
484
+ }
485
+ }
486
+
487
+ if (includeJs) {
488
+ for (const cand of candidates) {
489
+ if (this.hasPageJs(cand)) {
490
+ const { css: _c, js: _j, cssAttrs: _ca, ...jsAttrs } = options;
491
+ tags.push(this.pageJs(cand, jsAttrs));
492
+ break;
493
+ }
494
+ }
495
+ }
496
+
497
+ return tags.join('\n');
498
+ }
499
+
138
500
  /**
139
501
  * Build HTML attributes string
140
502
  */
@@ -460,6 +822,88 @@ function createHelpers(ctx) {
460
822
  return getAssetManager().img(src, alt, attrs);
461
823
  },
462
824
 
825
+ /**
826
+ * Generate Dev Live/Hot Reload script tag in development mode
827
+ * @returns {string} Script tag or empty string
828
+ */
829
+ devReload() {
830
+ const isDev = process.env.NODE_ENV !== 'production';
831
+ if (!isDev) return '';
832
+ return '<script defer src="/__webspresso/dev-reload.js"></script>';
833
+ },
834
+
835
+ /**
836
+ * Generate page-specific JS script tag
837
+ * @param {string} name - Page name (e.g. 'home', 'checkout', 'pages/checkout.js')
838
+ * @param {Object} [attrs={}] - Additional HTML attributes
839
+ * @returns {string}
840
+ */
841
+ pageJs(name, attrs = {}) {
842
+ return getAssetManager().pageJs(name, attrs);
843
+ },
844
+
845
+ /**
846
+ * Resolve page-specific JS script URL
847
+ * @param {string} name - Page name
848
+ * @returns {string}
849
+ */
850
+ pageJsUrl(name) {
851
+ return getAssetManager().pageJsUrl(name);
852
+ },
853
+
854
+ /**
855
+ * Generate page-specific CSS link tag
856
+ * @param {string} name - Page name (e.g. 'home', 'checkout', 'pages/checkout.css')
857
+ * @param {Object} [attrs={}] - Additional HTML attributes
858
+ * @returns {string}
859
+ */
860
+ pageCss(name, attrs = {}) {
861
+ return getAssetManager().pageCss(name, attrs);
862
+ },
863
+
864
+ /**
865
+ * Resolve page-specific CSS stylesheet URL
866
+ * @param {string} name - Page name
867
+ * @returns {string}
868
+ */
869
+ pageCssUrl(name) {
870
+ return getAssetManager().pageCssUrl(name);
871
+ },
872
+
873
+ /**
874
+ * Check if a page-specific JS script exists
875
+ * @param {string} name - Page name
876
+ * @returns {boolean}
877
+ */
878
+ hasPageJs(name) {
879
+ return getAssetManager().hasPageJs(name);
880
+ },
881
+
882
+ /**
883
+ * Check if a page-specific CSS stylesheet exists
884
+ * @param {string} name - Page name
885
+ * @returns {boolean}
886
+ */
887
+ hasPageCss(name) {
888
+ return getAssetManager().hasPageCss(name);
889
+ },
890
+
891
+ /**
892
+ * Generate combined page asset tags (link and/or script) for a given page name or current route
893
+ * @param {string|Object} [nameOrAttrs] - Page name (e.g. 'checkout') or options object
894
+ * @param {Object} [attrs={}] - Additional attributes
895
+ * @returns {string} HTML string
896
+ */
897
+ pageAssets(nameOrAttrs, attrs) {
898
+ if (typeof nameOrAttrs === 'string' && nameOrAttrs.trim()) {
899
+ return getAssetManager().pageAssets(nameOrAttrs, attrs);
900
+ }
901
+ const opts = (typeof nameOrAttrs === 'object' && nameOrAttrs !== null) ? nameOrAttrs : (attrs || {});
902
+ const routeFile = req?.context?.file;
903
+ const routePath = req?.context?.path || req?.path;
904
+ return getAssetManager().pageAssetsForRoute(routeFile, routePath, opts);
905
+ },
906
+
463
907
  /**
464
908
  * Date/time helpers using dayjs
465
909
  */
@@ -672,9 +1116,12 @@ function createHelpers(ctx) {
672
1116
  * @returns {string} Dev toolbar HTML
673
1117
  */
674
1118
  devToolbar(options = {}) {
1119
+ if (options && options.enabled === false) return '';
675
1120
  const injector = getScriptInjector();
676
1121
  const registeredPlugins = injector.getPlugins();
677
1122
  return generateDevToolbar({
1123
+ req,
1124
+ res,
678
1125
  ...options,
679
1126
  plugins: [...registeredPlugins, ...(options.plugins || [])]
680
1127
  });
@@ -829,24 +1276,86 @@ function resetScriptInjector() {
829
1276
  return globalInjector;
830
1277
  }
831
1278
 
1279
+ /**
1280
+ * Safely escape HTML string
1281
+ * @param {string} str
1282
+ * @returns {string}
1283
+ */
1284
+ function safeEscapeHtml(str) {
1285
+ if (!str) return '';
1286
+ return String(str)
1287
+ .replace(/&/g, '&amp;')
1288
+ .replace(/"/g, '&quot;')
1289
+ .replace(/'/g, '&#39;')
1290
+ .replace(/</g, '&lt;')
1291
+ .replace(/>/g, '&gt;');
1292
+ }
1293
+
832
1294
  /**
833
1295
  * Generate dev toolbar HTML
834
- * @param {Object} options - { plugins: Array, routes: Array }
1296
+ * @param {Object} options - { plugins: Array, routes: Array, req: Object, res: Object }
835
1297
  */
836
1298
  function generateDevToolbar(options = {}) {
837
- const { plugins = [], customLinks = [] } = options;
838
1299
  const isDev = process.env.NODE_ENV !== 'production';
839
1300
 
840
1301
  if (!isDev) return '';
841
1302
 
842
- // Default plugin links
1303
+ const { req = null, res = null } = options;
1304
+
1305
+ // Normalize custom links & plugins
1306
+ const rawCustomLinks = options.customLinks || options.links || [];
1307
+ const normalizedCustomLinks = rawCustomLinks.map(link => ({
1308
+ name: link.name || link.label || link.title || 'Link',
1309
+ path: link.path || link.url || link.href || '#',
1310
+ icon: link.icon || '🔗',
1311
+ description: link.description || '',
1312
+ }));
1313
+
1314
+ const rawPlugins = options.plugins || [];
1315
+ const normalizedPlugins = rawPlugins.map(p => ({
1316
+ name: p.name || p.label || p.title || 'Plugin',
1317
+ path: p.path || p.url || p.href || '#',
1318
+ icon: p.icon || '🔗',
1319
+ description: p.description || '',
1320
+ }));
1321
+
1322
+ // 1. Gather active request profiler metrics & DB queries
1323
+ let queries = [];
1324
+ let queryDurationMs = 0;
1325
+ let nPlusOneWarnings = [];
1326
+
1327
+ try {
1328
+ const { profilerStorage, detectNPlusOne } = require('./dev/profiler');
1329
+ const store = profilerStorage.getStore();
1330
+ if (store && Array.isArray(store.queries)) {
1331
+ queries = store.queries;
1332
+ queryDurationMs = queries.reduce((acc, q) => acc + (q.durationMs || 0), 0);
1333
+ nPlusOneWarnings = (Array.isArray(store.nPlusOneWarnings) && store.nPlusOneWarnings.length > 0)
1334
+ ? store.nPlusOneWarnings
1335
+ : detectNPlusOne(queries, { threshold: 4 });
1336
+ }
1337
+ } catch {
1338
+ // Profiler not active or async context lost
1339
+ }
1340
+
1341
+ // 2. Request context details
1342
+ const currentPath = (req && (req.originalUrl || req.path)) || '/';
1343
+ const currentMethod = (req && req.method) || 'GET';
1344
+ const routeFile = req?._webspressoRoute?.file || (req?.route && req.route.path) || '';
1345
+ const templateName = res?.locals?._template || req?._webspressoTemplate || '';
1346
+ const statusCode = res?.statusCode || 200;
1347
+ const urlParams = req?.params || {};
1348
+ const queryParams = req?.query || {};
1349
+
1350
+ // 3. Default plugin links
843
1351
  const defaultPlugins = [
844
1352
  { name: 'Dashboard', path: '/_webspresso', icon: '📊', description: 'Development Dashboard' },
845
1353
  { name: 'Admin', path: '/_admin', icon: '⚙️', description: 'Admin Panel' },
1354
+ { name: 'API Docs', path: '/_swagger', icon: '📖', description: 'Interactive API Docs & Playground' },
846
1355
  { name: 'Schema', path: '/_schema', icon: '🗂️', description: 'Schema Explorer' },
847
1356
  ];
848
1357
 
849
- const allPlugins = [...defaultPlugins, ...plugins, ...customLinks];
1358
+ const allPlugins = [...defaultPlugins, ...normalizedPlugins, ...normalizedCustomLinks];
850
1359
 
851
1360
  const toolbarStyles = `
852
1361
  <style id="webspresso-dev-toolbar-styles">
@@ -855,161 +1364,442 @@ function generateDevToolbar(options = {}) {
855
1364
  bottom: 0;
856
1365
  left: 0;
857
1366
  right: 0;
858
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
859
- border-top: 1px solid #0f3460;
1367
+ background: #0f172a;
1368
+ border-top: 1px solid #1e293b;
860
1369
  padding: 0;
861
1370
  z-index: 99999;
862
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
1371
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "SF Mono", monospace;
863
1372
  font-size: 12px;
864
- transform: translateY(calc(100% - 32px));
865
- transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1373
+ color: #f1f5f9;
1374
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4);
1375
+ transform: translateY(calc(100% - 36px));
1376
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
866
1377
  }
867
- #webspresso-dev-toolbar:hover,
868
1378
  #webspresso-dev-toolbar.expanded {
869
1379
  transform: translateY(0);
870
1380
  }
871
1381
  #webspresso-dev-toolbar-toggle {
872
1382
  position: absolute;
873
1383
  top: -24px;
874
- left: 50%;
875
- transform: translateX(-50%);
876
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
877
- border: 1px solid #0f3460;
1384
+ left: 20px;
1385
+ background: #0f172a;
1386
+ border: 1px solid #1e293b;
878
1387
  border-bottom: none;
879
- border-radius: 8px 8px 0 0;
880
- padding: 4px 16px;
1388
+ border-radius: 6px 6px 0 0;
1389
+ padding: 3px 12px;
881
1390
  cursor: pointer;
882
- color: #e94560;
883
- font-weight: 600;
1391
+ color: #38bdf8;
1392
+ font-weight: 700;
884
1393
  font-size: 10px;
885
- letter-spacing: 1px;
886
- text-transform: uppercase;
1394
+ letter-spacing: 0.5px;
1395
+ user-select: none;
887
1396
  }
888
1397
  #webspresso-dev-toolbar-header {
889
1398
  display: flex;
890
1399
  align-items: center;
891
1400
  justify-content: space-between;
892
- padding: 8px 16px;
893
- border-bottom: 1px solid #0f3460;
894
- background: rgba(0,0,0,0.2);
1401
+ padding: 6px 14px;
1402
+ height: 36px;
1403
+ border-bottom: 1px solid #1e293b;
1404
+ background: #0f172a;
1405
+ cursor: pointer;
1406
+ user-select: none;
1407
+ }
1408
+ .ws-header-left {
1409
+ display: flex;
1410
+ align-items: center;
1411
+ gap: 10px;
1412
+ overflow: hidden;
895
1413
  }
896
1414
  #webspresso-dev-toolbar-brand {
897
1415
  display: flex;
898
1416
  align-items: center;
899
- gap: 8px;
900
- color: #e94560;
1417
+ gap: 6px;
1418
+ color: #38bdf8;
901
1419
  font-weight: 700;
902
1420
  font-size: 11px;
903
1421
  letter-spacing: 0.5px;
1422
+ white-space: nowrap;
904
1423
  }
905
1424
  #webspresso-dev-toolbar-brand svg {
906
- width: 16px;
907
- height: 16px;
1425
+ width: 14px;
1426
+ height: 14px;
1427
+ }
1428
+ .ws-pill {
1429
+ display: inline-flex;
1430
+ align-items: center;
1431
+ gap: 5px;
1432
+ padding: 2px 8px;
1433
+ border-radius: 4px;
1434
+ font-size: 11px;
1435
+ background: #1e293b;
1436
+ border: 1px solid #334155;
1437
+ white-space: nowrap;
1438
+ }
1439
+ .ws-pill.clickable:hover {
1440
+ border-color: #38bdf8;
1441
+ cursor: pointer;
1442
+ }
1443
+ .ws-method {
1444
+ font-weight: 700;
1445
+ color: #38bdf8;
1446
+ }
1447
+ .ws-status-200 { color: #4ade80; }
1448
+ .ws-status-404, .ws-status-500 { color: #f87171; }
1449
+ .ws-query-pill {
1450
+ color: #cbd5e1;
1451
+ }
1452
+ .ws-warn-pill {
1453
+ background: rgba(234, 179, 8, 0.15);
1454
+ color: #facc15;
1455
+ border-color: rgba(234, 179, 8, 0.4);
908
1456
  }
909
1457
  #webspresso-dev-toolbar-info {
910
- color: #a1a1aa;
1458
+ color: #64748b;
1459
+ font-size: 11px;
1460
+ display: flex;
1461
+ align-items: center;
1462
+ gap: 12px;
1463
+ }
1464
+ .ws-expand-icon {
911
1465
  font-size: 10px;
1466
+ color: #94a3b8;
1467
+ padding: 2px 6px;
1468
+ border-radius: 4px;
1469
+ background: #1e293b;
912
1470
  }
913
1471
  #webspresso-dev-toolbar-content {
914
- display: flex;
915
- align-items: stretch;
916
- gap: 0;
917
- padding: 0;
918
- overflow-x: auto;
1472
+ display: none;
919
1473
  }
920
1474
  .webspresso-dev-toolbar-section {
921
1475
  display: flex;
922
1476
  align-items: center;
923
1477
  gap: 4px;
924
- padding: 12px 16px;
925
- border-right: 1px solid #0f3460;
926
- }
927
- .webspresso-dev-toolbar-section:last-child {
928
- border-right: none;
929
1478
  }
930
1479
  .webspresso-dev-toolbar-section-title {
931
- color: #a1a1aa;
1480
+ color: #64748b;
932
1481
  font-size: 9px;
933
1482
  text-transform: uppercase;
934
- letter-spacing: 1px;
935
- margin-right: 8px;
1483
+ margin-right: 6px;
936
1484
  }
937
1485
  .webspresso-dev-toolbar-link {
938
- display: flex;
1486
+ display: inline-flex;
939
1487
  align-items: center;
940
- gap: 6px;
941
- padding: 6px 12px;
942
- background: rgba(233, 69, 96, 0.1);
943
- border: 1px solid rgba(233, 69, 96, 0.2);
944
- border-radius: 6px;
945
- color: #f1f1f1;
1488
+ gap: 4px;
1489
+ padding: 3px 8px;
1490
+ background: #1e293b;
1491
+ border: 1px solid #334155;
1492
+ border-radius: 4px;
1493
+ color: #e2e8f0;
946
1494
  text-decoration: none;
947
- transition: all 0.2s ease;
1495
+ font-size: 11px;
1496
+ transition: all 0.15s ease;
948
1497
  white-space: nowrap;
949
1498
  }
950
1499
  .webspresso-dev-toolbar-link:hover {
951
- background: rgba(233, 69, 96, 0.2);
952
- border-color: #e94560;
1500
+ background: #334155;
1501
+ border-color: #38bdf8;
953
1502
  color: #fff;
954
- transform: translateY(-1px);
955
1503
  }
956
- .webspresso-dev-toolbar-link-icon {
957
- font-size: 14px;
1504
+ .webspresso-dev-toolbar-link-icon { font-size: 12px; }
1505
+ .webspresso-dev-toolbar-link-text { font-weight: 500; }
1506
+
1507
+ /* Drawer */
1508
+ .ws-drawer {
1509
+ height: 290px;
1510
+ display: flex;
1511
+ flex-direction: column;
1512
+ background: #090d16;
958
1513
  }
959
- .webspresso-dev-toolbar-link-text {
960
- font-weight: 500;
1514
+ .ws-drawer-tabs {
1515
+ display: flex;
1516
+ align-items: center;
1517
+ gap: 2px;
1518
+ padding: 6px 12px;
1519
+ background: #0f172a;
1520
+ border-bottom: 1px solid #1e293b;
961
1521
  }
962
- .webspresso-dev-toolbar-close {
963
- background: none;
1522
+ .ws-tab-btn {
1523
+ background: transparent;
964
1524
  border: none;
965
- color: #a1a1aa;
1525
+ color: #94a3b8;
1526
+ padding: 4px 12px;
1527
+ border-radius: 4px;
966
1528
  cursor: pointer;
967
- padding: 4px 8px;
968
- font-size: 16px;
969
- transition: color 0.2s;
1529
+ font-size: 11px;
1530
+ font-weight: 500;
1531
+ transition: all 0.15s ease;
970
1532
  }
971
- .webspresso-dev-toolbar-close:hover {
972
- color: #e94560;
1533
+ .ws-tab-btn:hover {
1534
+ color: #f1f5f9;
1535
+ background: #1e293b;
973
1536
  }
974
- @media (max-width: 768px) {
975
- #webspresso-dev-toolbar {
976
- font-size: 11px;
977
- }
978
- .webspresso-dev-toolbar-link {
979
- padding: 4px 8px;
980
- }
1537
+ .ws-tab-btn.active {
1538
+ color: #38bdf8;
1539
+ background: #1e293b;
1540
+ font-weight: 600;
1541
+ }
1542
+ .ws-drawer-body {
1543
+ flex: 1;
1544
+ overflow-y: auto;
1545
+ padding: 12px 16px;
1546
+ }
1547
+ .ws-panel {
1548
+ display: none;
1549
+ }
1550
+ .ws-panel.active {
1551
+ display: block;
1552
+ }
1553
+ .ws-query-table {
1554
+ width: 100%;
1555
+ border-collapse: collapse;
1556
+ font-size: 11px;
1557
+ }
1558
+ .ws-query-table th {
1559
+ text-align: left;
1560
+ color: #64748b;
1561
+ padding: 6px 8px;
1562
+ border-bottom: 1px solid #1e293b;
1563
+ font-weight: 600;
1564
+ text-transform: uppercase;
1565
+ font-size: 10px;
1566
+ }
1567
+ .ws-query-table td {
1568
+ padding: 6px 8px;
1569
+ border-bottom: 1px solid #141c2e;
1570
+ vertical-align: top;
1571
+ }
1572
+ .ws-table-pill {
1573
+ display: inline-block;
1574
+ padding: 1px 6px;
1575
+ border-radius: 3px;
1576
+ background: rgba(56, 189, 248, 0.15);
1577
+ color: #38bdf8;
1578
+ font-size: 10px;
1579
+ font-weight: 600;
1580
+ }
1581
+ .ws-duration-fast { color: #4ade80; font-weight: 600; }
1582
+ .ws-duration-slow { color: #facc15; font-weight: 700; }
1583
+ .ws-sql-code {
1584
+ font-family: 'SF Mono', monospace;
1585
+ color: #cbd5e1;
1586
+ word-break: break-all;
1587
+ }
1588
+ .ws-warn-box {
1589
+ background: rgba(234, 179, 8, 0.1);
1590
+ border: 1px solid rgba(234, 179, 8, 0.3);
1591
+ border-radius: 6px;
1592
+ padding: 8px 12px;
1593
+ margin-bottom: 10px;
1594
+ color: #facc15;
1595
+ font-size: 11px;
1596
+ }
1597
+ .ws-meta-grid {
1598
+ display: grid;
1599
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1600
+ gap: 10px;
1601
+ }
1602
+ .ws-meta-card {
1603
+ background: #0f172a;
1604
+ border: 1px solid #1e293b;
1605
+ border-radius: 6px;
1606
+ padding: 10px;
1607
+ }
1608
+ .ws-meta-card h4 {
1609
+ margin: 0 0 6px 0;
1610
+ font-size: 11px;
1611
+ color: #94a3b8;
1612
+ text-transform: uppercase;
1613
+ }
1614
+ .ws-meta-val {
1615
+ font-family: 'SF Mono', monospace;
1616
+ font-size: 12px;
1617
+ color: #e2e8f0;
1618
+ word-break: break-all;
1619
+ }
1620
+ .ws-tools-grid {
1621
+ display: grid;
1622
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
1623
+ gap: 10px;
1624
+ }
1625
+ .ws-tool-card {
1626
+ display: flex;
1627
+ align-items: center;
1628
+ gap: 8px;
1629
+ background: #0f172a;
1630
+ border: 1px solid #1e293b;
1631
+ border-radius: 6px;
1632
+ padding: 10px 12px;
1633
+ color: #e2e8f0;
1634
+ text-decoration: none;
1635
+ transition: all 0.15s ease;
1636
+ }
1637
+ .ws-tool-card:hover {
1638
+ background: #1e293b;
1639
+ border-color: #38bdf8;
1640
+ color: #fff;
981
1641
  }
982
1642
  </style>
983
1643
  `;
984
1644
 
1645
+ // Render plugin links
985
1646
  const pluginLinks = allPlugins.map(p => `
986
- <a href="${p.path}" class="webspresso-dev-toolbar-link" title="${p.description || p.name}">
1647
+ <a href="${p.path}" class="webspresso-dev-toolbar-link" title="${safeEscapeHtml(p.description || p.name)}">
987
1648
  <span class="webspresso-dev-toolbar-link-icon">${p.icon || '🔗'}</span>
988
- <span class="webspresso-dev-toolbar-link-text">${p.name}</span>
1649
+ <span class="webspresso-dev-toolbar-link-text">${safeEscapeHtml(p.name)}</span>
989
1650
  </a>
990
1651
  `).join('');
991
1652
 
1653
+ // Render Queries list
1654
+ const queriesHtml = queries.length === 0
1655
+ ? `<div style="color: #64748b; padding: 20px 0; text-align: center;">No database queries recorded for this page request.</div>`
1656
+ : `
1657
+ ${nPlusOneWarnings.map(w => `
1658
+ <div class="ws-warn-box">
1659
+ <strong>⚠️ Potential N+1 Query Warning:</strong> ${safeEscapeHtml(w.recommendation)}
1660
+ </div>
1661
+ `).join('')}
1662
+ <table class="ws-query-table">
1663
+ <thead>
1664
+ <tr>
1665
+ <th style="width: 70px;">Time</th>
1666
+ <th style="width: 110px;">Table</th>
1667
+ <th>SQL Query</th>
1668
+ </tr>
1669
+ </thead>
1670
+ <tbody>
1671
+ ${queries.map(q => `
1672
+ <tr>
1673
+ <td><span class="${(q.durationMs || 0) > 40 ? 'ws-duration-slow' : 'ws-duration-fast'}">${(q.durationMs || 0).toFixed(1)}ms</span></td>
1674
+ <td><span class="ws-table-pill">${safeEscapeHtml(q.table || 'raw')}</span></td>
1675
+ <td><span class="ws-sql-code">${safeEscapeHtml(q.sql || '')}</span></td>
1676
+ </tr>
1677
+ `).join('')}
1678
+ </tbody>
1679
+ </table>
1680
+ `;
1681
+
992
1682
  const toolbarHtml = `
993
1683
  ${toolbarStyles}
994
1684
  <div id="webspresso-dev-toolbar">
995
- <div id="webspresso-dev-toolbar-toggle">⚡ DEV</div>
1685
+ <div id="webspresso-dev-toolbar-toggle" title="Toggle DevTools (Alt+D)">⚡ DEV</div>
996
1686
  <div id="webspresso-dev-toolbar-header">
997
- <div id="webspresso-dev-toolbar-brand">
998
- <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
999
- <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" opacity="0.3"/>
1000
- <path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1001
- <path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1002
- </svg>
1003
- WEBSPRESSO DEV
1687
+ <div class="ws-header-left">
1688
+ <div id="webspresso-dev-toolbar-brand">
1689
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1690
+ <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" opacity="0.3"/>
1691
+ <path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1692
+ <path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1693
+ </svg>
1694
+ WEBSPRESSO DEV
1695
+ </div>
1696
+ <div class="ws-pill" title="Current Route">
1697
+ <span class="ws-method">${safeEscapeHtml(currentMethod)}</span>
1698
+ <span class="ws-status-${statusCode}">${statusCode}</span>
1699
+ <span style="color: #94a3b8;">${safeEscapeHtml(currentPath)}</span>
1700
+ </div>
1701
+ ${queries.length > 0 ? `
1702
+ <div class="ws-pill clickable ws-query-pill" data-open-tab="queries" title="View ${queries.length} queries">
1703
+ <span>🗄️ ${queries.length} queries</span>
1704
+ <span style="color: #64748b;">(${queryDurationMs.toFixed(1)}ms)</span>
1705
+ </div>
1706
+ ` : ''}
1707
+ ${nPlusOneWarnings.length > 0 ? `
1708
+ <div class="ws-pill clickable ws-warn-pill" data-open-tab="queries" title="${nPlusOneWarnings.length} N+1 Warning(s)">
1709
+ ⚠️ N+1 (${nPlusOneWarnings.length})
1710
+ </div>
1711
+ ` : ''}
1004
1712
  </div>
1005
1713
  <div id="webspresso-dev-toolbar-info">
1006
- Node ${process.version} | ${new Date().toLocaleTimeString()}
1714
+ <span style="display: none;" id="webspresso-dev-toolbar-content">
1715
+ <span class="webspresso-dev-toolbar-section">
1716
+ <span class="webspresso-dev-toolbar-section-title">Quick Links</span>
1717
+ ${pluginLinks}
1718
+ </span>
1719
+ </span>
1720
+ <span>Node ${process.version}</span>
1721
+ <span class="ws-expand-icon" id="ws-expand-arrow">▲</span>
1007
1722
  </div>
1008
1723
  </div>
1009
- <div id="webspresso-dev-toolbar-content">
1010
- <div class="webspresso-dev-toolbar-section">
1011
- <span class="webspresso-dev-toolbar-section-title">Quick Links</span>
1012
- ${pluginLinks}
1724
+
1725
+ <!-- Drawer Content -->
1726
+ <div class="ws-drawer" id="webspresso-dev-toolbar-drawer">
1727
+ <div class="ws-drawer-tabs">
1728
+ <button class="ws-tab-btn active" data-tab="queries">🗄️ SQL Queries (${queries.length})</button>
1729
+ <button class="ws-tab-btn" data-tab="page">📄 Route & Page</button>
1730
+ <button class="ws-tab-btn" data-tab="tools">🚀 Quick Links</button>
1731
+ <button class="ws-tab-btn" data-tab="system">💻 System</button>
1732
+ </div>
1733
+ <div class="ws-drawer-body">
1734
+ <!-- Tab 1: Queries -->
1735
+ <div class="ws-panel active" data-panel="queries">
1736
+ ${queriesHtml}
1737
+ </div>
1738
+
1739
+ <!-- Tab 2: Route & Page -->
1740
+ <div class="ws-panel" data-panel="page">
1741
+ <div class="ws-meta-grid">
1742
+ <div class="ws-meta-card">
1743
+ <h4>Route Path</h4>
1744
+ <div class="ws-meta-val">${safeEscapeHtml(currentPath)}</div>
1745
+ </div>
1746
+ <div class="ws-meta-card">
1747
+ <h4>Route File</h4>
1748
+ <div class="ws-meta-val">${safeEscapeHtml(routeFile || 'pages/index.njk')}</div>
1749
+ </div>
1750
+ ${templateName ? `
1751
+ <div class="ws-meta-card">
1752
+ <h4>Template</h4>
1753
+ <div class="ws-meta-val">${safeEscapeHtml(templateName)}</div>
1754
+ </div>
1755
+ ` : ''}
1756
+ <div class="ws-meta-card">
1757
+ <h4>Parameters</h4>
1758
+ <div class="ws-meta-val">${safeEscapeHtml(JSON.stringify(urlParams))}</div>
1759
+ </div>
1760
+ <div class="ws-meta-card">
1761
+ <h4>Query</h4>
1762
+ <div class="ws-meta-val">${safeEscapeHtml(JSON.stringify(queryParams))}</div>
1763
+ </div>
1764
+ </div>
1765
+ </div>
1766
+
1767
+ <!-- Tab 3: Tools -->
1768
+ <div class="ws-panel" data-panel="tools">
1769
+ <div class="ws-tools-grid">
1770
+ ${allPlugins.map(p => `
1771
+ <a href="${p.path}" class="ws-tool-card webspresso-dev-toolbar-link" title="${safeEscapeHtml(p.description || p.name)}">
1772
+ <span class="webspresso-dev-toolbar-link-icon" style="font-size: 18px;">${p.icon || '🔗'}</span>
1773
+ <div>
1774
+ <div class="webspresso-dev-toolbar-link-text" style="font-weight: 600;">${safeEscapeHtml(p.name)}</div>
1775
+ <div style="font-size: 10px; color: #64748b;">${safeEscapeHtml(p.path)}</div>
1776
+ </div>
1777
+ </a>
1778
+ `).join('')}
1779
+ </div>
1780
+ </div>
1781
+
1782
+ <!-- Tab 4: System -->
1783
+ <div class="ws-panel" data-panel="system">
1784
+ <div class="ws-meta-grid">
1785
+ <div class="ws-meta-card">
1786
+ <h4>Node Version</h4>
1787
+ <div class="ws-meta-val">${process.version} (${process.platform} ${process.arch})</div>
1788
+ </div>
1789
+ <div class="ws-meta-card">
1790
+ <h4>Environment</h4>
1791
+ <div class="ws-meta-val">${process.env.NODE_ENV || 'development'}</div>
1792
+ </div>
1793
+ <div class="ws-meta-card">
1794
+ <h4>Memory Usage</h4>
1795
+ <div class="ws-meta-val">${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB / ${Math.round(process.memoryUsage().heapTotal / 1024 / 1024)}MB</div>
1796
+ </div>
1797
+ <div class="ws-meta-card">
1798
+ <h4>Process Uptime</h4>
1799
+ <div class="ws-meta-val">${Math.round(process.uptime())}s</div>
1800
+ </div>
1801
+ </div>
1802
+ </div>
1013
1803
  </div>
1014
1804
  </div>
1015
1805
  </div>
@@ -1017,11 +1807,56 @@ function generateDevToolbar(options = {}) {
1017
1807
  (function() {
1018
1808
  const toolbar = document.getElementById('webspresso-dev-toolbar');
1019
1809
  const toggle = document.getElementById('webspresso-dev-toolbar-toggle');
1020
- if (toolbar && toggle) {
1021
- toggle.addEventListener('click', function() {
1022
- toolbar.classList.toggle('expanded');
1810
+ const header = document.getElementById('webspresso-dev-toolbar-header');
1811
+ const arrow = document.getElementById('ws-expand-arrow');
1812
+ const tabBtns = toolbar.querySelectorAll('.ws-tab-btn');
1813
+ const panels = toolbar.querySelectorAll('.ws-panel');
1814
+
1815
+ function toggleToolbar() {
1816
+ toolbar.classList.toggle('expanded');
1817
+ if (arrow) arrow.textContent = toolbar.classList.contains('expanded') ? '▼' : '▲';
1818
+ }
1819
+
1820
+ function switchTab(targetTab) {
1821
+ tabBtns.forEach(btn => {
1822
+ btn.classList.toggle('active', btn.getAttribute('data-tab') === targetTab);
1823
+ });
1824
+ panels.forEach(p => {
1825
+ p.classList.toggle('active', p.getAttribute('data-panel') === targetTab);
1023
1826
  });
1024
1827
  }
1828
+
1829
+ if (toggle) toggle.addEventListener('click', toggleToolbar);
1830
+ if (header) {
1831
+ header.addEventListener('click', function(e) {
1832
+ if (e.target.closest('[data-open-tab]')) return;
1833
+ toggleToolbar();
1834
+ });
1835
+ }
1836
+
1837
+ toolbar.querySelectorAll('[data-open-tab]').forEach(elem => {
1838
+ elem.addEventListener('click', function(e) {
1839
+ e.stopPropagation();
1840
+ const tab = elem.getAttribute('data-open-tab');
1841
+ switchTab(tab);
1842
+ if (!toolbar.classList.contains('expanded')) toggleToolbar();
1843
+ });
1844
+ });
1845
+
1846
+ tabBtns.forEach(btn => {
1847
+ btn.addEventListener('click', function(e) {
1848
+ e.stopPropagation();
1849
+ switchTab(btn.getAttribute('data-tab'));
1850
+ });
1851
+ });
1852
+
1853
+ // Keyboard shortcut: Alt + D
1854
+ window.addEventListener('keydown', function(e) {
1855
+ if (e.altKey && (e.key === 'd' || e.key === 'D')) {
1856
+ e.preventDefault();
1857
+ toggleToolbar();
1858
+ }
1859
+ });
1025
1860
  })();
1026
1861
  </script>
1027
1862
  `;
@@ -1073,6 +1908,40 @@ const utils = {
1073
1908
 
1074
1909
  isProd() {
1075
1910
  return process.env.NODE_ENV === 'production';
1911
+ },
1912
+
1913
+ devReload() {
1914
+ const isDev = process.env.NODE_ENV !== 'production';
1915
+ if (!isDev) return '';
1916
+ return '<script defer src="/__webspresso/dev-reload.js"></script>';
1917
+ },
1918
+
1919
+ pageJs(name, attrs = {}) {
1920
+ return getAssetManager().pageJs(name, attrs);
1921
+ },
1922
+
1923
+ pageJsUrl(name) {
1924
+ return getAssetManager().pageJsUrl(name);
1925
+ },
1926
+
1927
+ pageCss(name, attrs = {}) {
1928
+ return getAssetManager().pageCss(name, attrs);
1929
+ },
1930
+
1931
+ pageCssUrl(name) {
1932
+ return getAssetManager().pageCssUrl(name);
1933
+ },
1934
+
1935
+ hasPageJs(name) {
1936
+ return getAssetManager().hasPageJs(name);
1937
+ },
1938
+
1939
+ hasPageCss(name) {
1940
+ return getAssetManager().hasPageCss(name);
1941
+ },
1942
+
1943
+ pageAssets(nameOrAttrs, attrs) {
1944
+ return getAssetManager().pageAssets(nameOrAttrs, attrs);
1076
1945
  }
1077
1946
  };
1078
1947