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