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.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- 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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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, '&')
|
|
1279
|
+
.replace(/"/g, '"')
|
|
1280
|
+
.replace(/'/g, ''')
|
|
1281
|
+
.replace(/</g, '<')
|
|
1282
|
+
.replace(/>/g, '>');
|
|
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
|
-
|
|
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, ...
|
|
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:
|
|
859
|
-
border-top: 1px solid #
|
|
1358
|
+
background: #0f172a;
|
|
1359
|
+
border-top: 1px solid #1e293b;
|
|
860
1360
|
padding: 0;
|
|
861
1361
|
z-index: 99999;
|
|
862
|
-
font-family:
|
|
1362
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "SF Mono", monospace;
|
|
863
1363
|
font-size: 12px;
|
|
864
|
-
|
|
865
|
-
|
|
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:
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
border: 1px solid #0f3460;
|
|
1375
|
+
left: 20px;
|
|
1376
|
+
background: #0f172a;
|
|
1377
|
+
border: 1px solid #1e293b;
|
|
878
1378
|
border-bottom: none;
|
|
879
|
-
border-radius:
|
|
880
|
-
padding:
|
|
1379
|
+
border-radius: 6px 6px 0 0;
|
|
1380
|
+
padding: 3px 12px;
|
|
881
1381
|
cursor: pointer;
|
|
882
|
-
color: #
|
|
883
|
-
font-weight:
|
|
1382
|
+
color: #38bdf8;
|
|
1383
|
+
font-weight: 700;
|
|
884
1384
|
font-size: 10px;
|
|
885
|
-
letter-spacing:
|
|
886
|
-
|
|
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:
|
|
893
|
-
|
|
894
|
-
|
|
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:
|
|
900
|
-
color: #
|
|
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:
|
|
907
|
-
height:
|
|
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: #
|
|
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:
|
|
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: #
|
|
1471
|
+
color: #64748b;
|
|
932
1472
|
font-size: 9px;
|
|
933
1473
|
text-transform: uppercase;
|
|
934
|
-
|
|
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:
|
|
941
|
-
padding:
|
|
942
|
-
background:
|
|
943
|
-
border: 1px solid
|
|
944
|
-
border-radius:
|
|
945
|
-
color: #
|
|
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
|
-
|
|
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:
|
|
952
|
-
border-color: #
|
|
1491
|
+
background: #334155;
|
|
1492
|
+
border-color: #38bdf8;
|
|
953
1493
|
color: #fff;
|
|
954
|
-
transform: translateY(-1px);
|
|
955
1494
|
}
|
|
956
|
-
.webspresso-dev-toolbar-link-icon {
|
|
957
|
-
|
|
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
|
-
.
|
|
960
|
-
|
|
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
|
-
.
|
|
963
|
-
background:
|
|
1513
|
+
.ws-tab-btn {
|
|
1514
|
+
background: transparent;
|
|
964
1515
|
border: none;
|
|
965
|
-
color: #
|
|
1516
|
+
color: #94a3b8;
|
|
1517
|
+
padding: 4px 12px;
|
|
1518
|
+
border-radius: 4px;
|
|
966
1519
|
cursor: pointer;
|
|
967
|
-
|
|
968
|
-
font-
|
|
969
|
-
transition:
|
|
1520
|
+
font-size: 11px;
|
|
1521
|
+
font-weight: 500;
|
|
1522
|
+
transition: all 0.15s ease;
|
|
970
1523
|
}
|
|
971
|
-
.
|
|
972
|
-
color: #
|
|
1524
|
+
.ws-tab-btn:hover {
|
|
1525
|
+
color: #f1f5f9;
|
|
1526
|
+
background: #1e293b;
|
|
973
1527
|
}
|
|
974
|
-
|
|
975
|
-
#
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
|
998
|
-
<
|
|
999
|
-
<
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|