webspresso 0.0.96 → 0.0.98

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