webspresso 0.0.27 → 0.0.28
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/README.md +124 -1
- package/package.json +1 -1
- package/plugins/sitemap.js +276 -61
- package/src/helpers.js +374 -1
- package/src/plugin-manager.js +48 -0
package/README.md
CHANGED
|
@@ -318,7 +318,10 @@ const { app } = createApp({
|
|
|
318
318
|
},
|
|
319
319
|
|
|
320
320
|
// Option 2: Template path (rendered with Nunjucks)
|
|
321
|
-
serverError: 'errors/500.njk'
|
|
321
|
+
serverError: 'errors/500.njk',
|
|
322
|
+
|
|
323
|
+
// Timeout error page (503)
|
|
324
|
+
timeout: 'errors/503.njk'
|
|
322
325
|
}
|
|
323
326
|
});
|
|
324
327
|
```
|
|
@@ -326,6 +329,20 @@ const { app } = createApp({
|
|
|
326
329
|
Error templates receive these variables:
|
|
327
330
|
- `404.njk`: `{ url, method }`
|
|
328
331
|
- `500.njk`: `{ error, status, isDev }`
|
|
332
|
+
- `503.njk`: `{ url, method, isDev }`
|
|
333
|
+
|
|
334
|
+
**Request Timeout:**
|
|
335
|
+
|
|
336
|
+
Configure request timeout with `connect-timeout`:
|
|
337
|
+
|
|
338
|
+
```javascript
|
|
339
|
+
const { app } = createApp({
|
|
340
|
+
pagesDir: './pages',
|
|
341
|
+
timeout: '30s', // Default: 30 seconds
|
|
342
|
+
// timeout: '1m', // 1 minute
|
|
343
|
+
// timeout: false, // Disable timeout
|
|
344
|
+
});
|
|
345
|
+
```
|
|
329
346
|
|
|
330
347
|
**Asset Management:**
|
|
331
348
|
|
|
@@ -455,9 +472,37 @@ Options:
|
|
|
455
472
|
|
|
456
473
|
**Sitemap Plugin:**
|
|
457
474
|
- Generates `/sitemap.xml` from routes automatically
|
|
475
|
+
- **Dynamic Database Content**: Generate URLs from database records
|
|
458
476
|
- Excludes dynamic routes and API endpoints
|
|
459
477
|
- Supports i18n with hreflang tags
|
|
460
478
|
- Generates `/robots.txt`
|
|
479
|
+
- Configurable caching for performance
|
|
480
|
+
|
|
481
|
+
```javascript
|
|
482
|
+
sitemapPlugin({
|
|
483
|
+
hostname: 'https://example.com',
|
|
484
|
+
db, // Database instance
|
|
485
|
+
dynamicSources: [
|
|
486
|
+
{
|
|
487
|
+
model: 'Post', // Model name
|
|
488
|
+
urlPattern: '/blog/:slug', // URL pattern
|
|
489
|
+
lastmodField: 'updated_at', // Field for lastmod
|
|
490
|
+
filter: (r) => r.published, // Filter records
|
|
491
|
+
priority: 0.9,
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
// Custom query function
|
|
495
|
+
query: async (db) => {
|
|
496
|
+
return db.getRepository('Product')
|
|
497
|
+
.query()
|
|
498
|
+
.where('active', true)
|
|
499
|
+
.list();
|
|
500
|
+
},
|
|
501
|
+
urlPattern: '/products/:slug',
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
})
|
|
505
|
+
```
|
|
461
506
|
|
|
462
507
|
**Analytics Plugin:**
|
|
463
508
|
- Google Analytics (GA4) and Google Ads
|
|
@@ -544,6 +589,84 @@ function myPluginFactory(options = {}) {
|
|
|
544
589
|
}
|
|
545
590
|
```
|
|
546
591
|
|
|
592
|
+
### Script Injection
|
|
593
|
+
|
|
594
|
+
Plugins can inject content into templates dynamically:
|
|
595
|
+
|
|
596
|
+
```javascript
|
|
597
|
+
const myPlugin = {
|
|
598
|
+
name: 'my-plugin',
|
|
599
|
+
version: '1.0.0',
|
|
600
|
+
|
|
601
|
+
register(ctx) {
|
|
602
|
+
// Inject into <head> section
|
|
603
|
+
ctx.injectHead('<meta name="my-plugin" content="enabled">', { priority: 10 });
|
|
604
|
+
|
|
605
|
+
// Inject at end of <body>
|
|
606
|
+
ctx.injectBody('<script src="/my-plugin.js"></script>');
|
|
607
|
+
|
|
608
|
+
// Inject CSS styles
|
|
609
|
+
ctx.injectStyle(`
|
|
610
|
+
.my-plugin-widget { display: block; }
|
|
611
|
+
`);
|
|
612
|
+
|
|
613
|
+
// Register link for dev toolbar
|
|
614
|
+
ctx.registerDevLink({
|
|
615
|
+
name: 'My Plugin',
|
|
616
|
+
path: '/my-plugin',
|
|
617
|
+
icon: '🔌',
|
|
618
|
+
description: 'My plugin dashboard'
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
**Template Usage:**
|
|
625
|
+
|
|
626
|
+
```njk
|
|
627
|
+
<head>
|
|
628
|
+
{# Injected head content from plugins #}
|
|
629
|
+
{{ fsy.injectHead() | safe }}
|
|
630
|
+
</head>
|
|
631
|
+
<body>
|
|
632
|
+
...
|
|
633
|
+
{# Injected body content from plugins #}
|
|
634
|
+
{{ fsy.injectBody() | safe }}
|
|
635
|
+
|
|
636
|
+
{# Dev toolbar (only in development) #}
|
|
637
|
+
{{ fsy.devToolbar() | safe }}
|
|
638
|
+
</body>
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
### Dev Toolbar
|
|
642
|
+
|
|
643
|
+
A development toolbar appears at the bottom of pages in development mode:
|
|
644
|
+
|
|
645
|
+
- **Quick Links**: Dashboard, Admin Panel, Schema Explorer
|
|
646
|
+
- **Plugin Links**: Plugins can register custom links
|
|
647
|
+
- **Hover to Expand**: Minimized by default, expands on hover
|
|
648
|
+
- **Auto-hidden**: Not shown in production mode
|
|
649
|
+
|
|
650
|
+
```javascript
|
|
651
|
+
// Register custom link from plugin
|
|
652
|
+
ctx.registerDevLink({
|
|
653
|
+
name: 'API Docs',
|
|
654
|
+
path: '/docs/api',
|
|
655
|
+
icon: '📚',
|
|
656
|
+
description: 'API Documentation'
|
|
657
|
+
});
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
**Custom Links in Templates:**
|
|
661
|
+
|
|
662
|
+
```njk
|
|
663
|
+
{{ fsy.devToolbar({
|
|
664
|
+
customLinks: [
|
|
665
|
+
{ name: 'Logs', path: '/logs', icon: '📋' }
|
|
666
|
+
]
|
|
667
|
+
}) | safe }}
|
|
668
|
+
```
|
|
669
|
+
|
|
547
670
|
## File-Based Routing
|
|
548
671
|
|
|
549
672
|
### SSR Pages
|
package/package.json
CHANGED
package/plugins/sitemap.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webspresso Sitemap Plugin
|
|
3
|
-
* Generates XML sitemap from registered routes
|
|
3
|
+
* Generates XML sitemap from registered routes with dynamic database support
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { matchPattern } = require('../src/plugin-manager');
|
|
@@ -18,6 +18,40 @@ function escapeXml(str) {
|
|
|
18
18
|
.replace(/'/g, ''');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Format date to ISO string for sitemap
|
|
23
|
+
*/
|
|
24
|
+
function formatLastmod(date) {
|
|
25
|
+
if (!date) return null;
|
|
26
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
27
|
+
if (isNaN(d.getTime())) return null;
|
|
28
|
+
return d.toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Replace URL pattern placeholders with values
|
|
33
|
+
* @param {string} pattern - URL pattern (e.g., '/blog/:slug' or '/posts/[id]')
|
|
34
|
+
* @param {Object} record - Record with values
|
|
35
|
+
* @param {Object} fieldMapping - Mapping of param names to field names
|
|
36
|
+
*/
|
|
37
|
+
function buildUrlFromPattern(pattern, record, fieldMapping = {}) {
|
|
38
|
+
let url = pattern;
|
|
39
|
+
|
|
40
|
+
// Handle :param style
|
|
41
|
+
url = url.replace(/:(\w+)/g, (match, param) => {
|
|
42
|
+
const field = fieldMapping[param] || param;
|
|
43
|
+
return record[field] !== undefined ? encodeURIComponent(record[field]) : match;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Handle [param] style
|
|
47
|
+
url = url.replace(/\[(\w+)\]/g, (match, param) => {
|
|
48
|
+
const field = fieldMapping[param] || param;
|
|
49
|
+
return record[field] !== undefined ? encodeURIComponent(record[field]) : match;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
/**
|
|
22
56
|
* Generate sitemap XML content
|
|
23
57
|
*/
|
|
@@ -76,6 +110,20 @@ function generateRobotsTxt(hostname, options = {}) {
|
|
|
76
110
|
return txt;
|
|
77
111
|
}
|
|
78
112
|
|
|
113
|
+
/**
|
|
114
|
+
* @typedef {Object} DynamicSource
|
|
115
|
+
* @property {string} [model] - Model name to query from database
|
|
116
|
+
* @property {Function} [query] - Custom query function: (db) => Promise<Array>
|
|
117
|
+
* @property {string} urlPattern - URL pattern with placeholders (e.g., '/blog/:slug' or '/posts/[id]')
|
|
118
|
+
* @property {Object} [fields] - Field mapping { urlParam: recordField }
|
|
119
|
+
* @property {string} [lastmodField] - Field name for lastmod date
|
|
120
|
+
* @property {string} [changefreq] - Change frequency for these URLs
|
|
121
|
+
* @property {number} [priority] - Priority for these URLs (0.0 - 1.0)
|
|
122
|
+
* @property {boolean} [i18n] - Enable i18n for these URLs (default: true)
|
|
123
|
+
* @property {Function} [filter] - Filter function: (record) => boolean
|
|
124
|
+
* @property {Function} [transform] - Transform function: (record) => record
|
|
125
|
+
*/
|
|
126
|
+
|
|
79
127
|
/**
|
|
80
128
|
* Create the sitemap plugin
|
|
81
129
|
* @param {Object} options - Plugin options
|
|
@@ -87,6 +135,8 @@ function generateRobotsTxt(hostname, options = {}) {
|
|
|
87
135
|
* @param {Array<string>} options.locales - Supported locales (defaults to env SUPPORTED_LOCALES)
|
|
88
136
|
* @param {boolean} options.robots - Generate robots.txt endpoint
|
|
89
137
|
* @param {Array<string>} options.robotsDisallow - Paths to disallow in robots.txt
|
|
138
|
+
* @param {Array<DynamicSource>} options.dynamicSources - Dynamic URL sources from database
|
|
139
|
+
* @param {Object} options.db - Database instance for dynamic queries
|
|
90
140
|
*/
|
|
91
141
|
function sitemapPlugin(options = {}) {
|
|
92
142
|
const {
|
|
@@ -97,16 +147,172 @@ function sitemapPlugin(options = {}) {
|
|
|
97
147
|
i18n = false,
|
|
98
148
|
locales = (process.env.SUPPORTED_LOCALES || 'en').split(','),
|
|
99
149
|
robots = true,
|
|
100
|
-
robotsDisallow = []
|
|
150
|
+
robotsDisallow = [],
|
|
151
|
+
dynamicSources = [],
|
|
152
|
+
db = null
|
|
101
153
|
} = options;
|
|
102
154
|
|
|
103
155
|
// Storage for dynamic URLs and exclusions
|
|
104
156
|
const dynamicUrls = [];
|
|
105
157
|
const dynamicExclusions = [...exclude];
|
|
158
|
+
const registeredSources = [...dynamicSources];
|
|
159
|
+
|
|
160
|
+
// Cached URLs (for dynamic sources)
|
|
161
|
+
let cachedUrls = null;
|
|
162
|
+
let cacheTime = null;
|
|
163
|
+
const cacheMaxAge = options.cacheMaxAge || 5 * 60 * 1000; // 5 minutes default
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fetch URLs from dynamic sources
|
|
167
|
+
*/
|
|
168
|
+
async function fetchDynamicSourceUrls(dbInstance) {
|
|
169
|
+
const urls = [];
|
|
170
|
+
|
|
171
|
+
for (const source of registeredSources) {
|
|
172
|
+
try {
|
|
173
|
+
let records = [];
|
|
174
|
+
|
|
175
|
+
// Get records from model or custom query
|
|
176
|
+
if (source.query && typeof source.query === 'function') {
|
|
177
|
+
records = await source.query(dbInstance);
|
|
178
|
+
} else if (source.model && dbInstance) {
|
|
179
|
+
const repo = dbInstance.getRepository(source.model);
|
|
180
|
+
if (repo) {
|
|
181
|
+
records = await repo.findAll();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Apply filter if provided
|
|
186
|
+
if (source.filter && typeof source.filter === 'function') {
|
|
187
|
+
records = records.filter(source.filter);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Transform records to URLs
|
|
191
|
+
for (let record of records) {
|
|
192
|
+
// Apply transform if provided
|
|
193
|
+
if (source.transform && typeof source.transform === 'function') {
|
|
194
|
+
record = source.transform(record);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const path = buildUrlFromPattern(
|
|
198
|
+
source.urlPattern,
|
|
199
|
+
record,
|
|
200
|
+
source.fields || {}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Get lastmod from record if field specified
|
|
204
|
+
let lastmod = null;
|
|
205
|
+
if (source.lastmodField && record[source.lastmodField]) {
|
|
206
|
+
lastmod = formatLastmod(record[source.lastmodField]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
urls.push({
|
|
210
|
+
path,
|
|
211
|
+
changefreq: source.changefreq || changefreq,
|
|
212
|
+
priority: source.priority || priority,
|
|
213
|
+
lastmod,
|
|
214
|
+
i18n: source.i18n !== false,
|
|
215
|
+
_source: source.model || 'custom'
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`Sitemap: Error fetching from source ${source.model || 'custom'}:`, err.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return urls;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build all sitemap URLs
|
|
228
|
+
*/
|
|
229
|
+
async function buildAllUrls(routes, dbInstance) {
|
|
230
|
+
const urls = [];
|
|
231
|
+
const hostnameNormalized = hostname.replace(/\/$/, '');
|
|
232
|
+
|
|
233
|
+
// Filter static routes for sitemap
|
|
234
|
+
const sitemapRoutes = routes
|
|
235
|
+
.filter(r => r.type === 'ssr')
|
|
236
|
+
.filter(r => !r.isDynamic) // Exclude dynamic routes (they come from dynamicSources)
|
|
237
|
+
.filter(r => {
|
|
238
|
+
// Check exclusion patterns
|
|
239
|
+
for (const pattern of dynamicExclusions) {
|
|
240
|
+
if (matchPattern(r.pattern, pattern)) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Add static route URLs
|
|
248
|
+
for (const route of sitemapRoutes) {
|
|
249
|
+
const baseUrl = `${hostnameNormalized}${route.pattern}`;
|
|
250
|
+
|
|
251
|
+
const urlEntry = {
|
|
252
|
+
loc: baseUrl,
|
|
253
|
+
changefreq,
|
|
254
|
+
priority
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Add i18n alternates
|
|
258
|
+
if (i18n && locales.length > 1) {
|
|
259
|
+
urlEntry.alternates = locales.map(lang => ({
|
|
260
|
+
lang,
|
|
261
|
+
href: `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}lang=${lang}`
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
urls.push(urlEntry);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Add manually added dynamic URLs
|
|
269
|
+
for (const dynUrl of dynamicUrls) {
|
|
270
|
+
const urlEntry = {
|
|
271
|
+
loc: `${hostnameNormalized}${dynUrl.path}`,
|
|
272
|
+
changefreq: dynUrl.changefreq || changefreq,
|
|
273
|
+
priority: dynUrl.priority || priority,
|
|
274
|
+
lastmod: dynUrl.lastmod
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (i18n && locales.length > 1 && dynUrl.i18n !== false) {
|
|
278
|
+
urlEntry.alternates = locales.map(lang => ({
|
|
279
|
+
lang,
|
|
280
|
+
href: `${urlEntry.loc}${urlEntry.loc.includes('?') ? '&' : '?'}lang=${lang}`
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
urls.push(urlEntry);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Fetch URLs from dynamic sources (database or custom queries)
|
|
288
|
+
if (registeredSources.length > 0) {
|
|
289
|
+
const sourceUrls = await fetchDynamicSourceUrls(dbInstance);
|
|
290
|
+
|
|
291
|
+
for (const dynUrl of sourceUrls) {
|
|
292
|
+
const urlEntry = {
|
|
293
|
+
loc: `${hostnameNormalized}${dynUrl.path}`,
|
|
294
|
+
changefreq: dynUrl.changefreq,
|
|
295
|
+
priority: dynUrl.priority,
|
|
296
|
+
lastmod: dynUrl.lastmod
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (i18n && locales.length > 1 && dynUrl.i18n !== false) {
|
|
300
|
+
urlEntry.alternates = locales.map(lang => ({
|
|
301
|
+
lang,
|
|
302
|
+
href: `${urlEntry.loc}${urlEntry.loc.includes('?') ? '&' : '?'}lang=${lang}`
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
urls.push(urlEntry);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return urls;
|
|
311
|
+
}
|
|
106
312
|
|
|
107
313
|
return {
|
|
108
314
|
name: 'sitemap',
|
|
109
|
-
version: '
|
|
315
|
+
version: '2.0.0',
|
|
110
316
|
_options: options,
|
|
111
317
|
|
|
112
318
|
/**
|
|
@@ -120,6 +326,7 @@ function sitemapPlugin(options = {}) {
|
|
|
120
326
|
*/
|
|
121
327
|
addUrl(path, opts = {}) {
|
|
122
328
|
dynamicUrls.push({ path, ...opts });
|
|
329
|
+
cachedUrls = null; // Invalidate cache
|
|
123
330
|
},
|
|
124
331
|
|
|
125
332
|
/**
|
|
@@ -128,13 +335,41 @@ function sitemapPlugin(options = {}) {
|
|
|
128
335
|
*/
|
|
129
336
|
exclude(pattern) {
|
|
130
337
|
dynamicExclusions.push(pattern);
|
|
338
|
+
cachedUrls = null; // Invalidate cache
|
|
131
339
|
},
|
|
132
340
|
|
|
133
341
|
/**
|
|
134
|
-
*
|
|
342
|
+
* Add a dynamic source for URLs from database
|
|
343
|
+
* @param {DynamicSource} source - Dynamic source configuration
|
|
344
|
+
*/
|
|
345
|
+
addDynamicSource(source) {
|
|
346
|
+
if (!source.urlPattern) {
|
|
347
|
+
throw new Error('urlPattern is required for dynamic source');
|
|
348
|
+
}
|
|
349
|
+
registeredSources.push(source);
|
|
350
|
+
cachedUrls = null; // Invalidate cache
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get all manually added URLs
|
|
135
355
|
*/
|
|
136
356
|
getUrls() {
|
|
137
357
|
return [...dynamicUrls];
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get all registered dynamic sources
|
|
362
|
+
*/
|
|
363
|
+
getDynamicSources() {
|
|
364
|
+
return [...registeredSources];
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Invalidate URL cache (useful after database changes)
|
|
369
|
+
*/
|
|
370
|
+
invalidateCache() {
|
|
371
|
+
cachedUrls = null;
|
|
372
|
+
cacheTime = null;
|
|
138
373
|
}
|
|
139
374
|
},
|
|
140
375
|
|
|
@@ -142,66 +377,39 @@ function sitemapPlugin(options = {}) {
|
|
|
142
377
|
* Called after routes are mounted
|
|
143
378
|
*/
|
|
144
379
|
onRoutesReady(ctx) {
|
|
145
|
-
|
|
146
|
-
const sitemapRoutes = ctx.routes
|
|
147
|
-
.filter(r => r.type === 'ssr')
|
|
148
|
-
.filter(r => !r.isDynamic) // Exclude dynamic routes
|
|
149
|
-
.filter(r => {
|
|
150
|
-
// Check exclusion patterns
|
|
151
|
-
for (const pattern of dynamicExclusions) {
|
|
152
|
-
if (matchPattern(r.pattern, pattern)) {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return true;
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Build URLs
|
|
160
|
-
const urls = [];
|
|
380
|
+
const dbInstance = db || ctx.options?.db;
|
|
161
381
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
lastmod: dynUrl.lastmod
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
if (i18n && locales.length > 1 && dynUrl.i18n !== false) {
|
|
192
|
-
urlEntry.alternates = locales.map(lang => ({
|
|
193
|
-
lang,
|
|
194
|
-
href: `${urlEntry.loc}${urlEntry.loc.includes('?') ? '&' : '?'}lang=${lang}`
|
|
382
|
+
// Register sitemap route (async handler for dynamic sources)
|
|
383
|
+
ctx.addRoute('get', '/sitemap.xml', async (req, res) => {
|
|
384
|
+
try {
|
|
385
|
+
// Check cache
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
if (cachedUrls && cacheTime && (now - cacheTime) < cacheMaxAge) {
|
|
388
|
+
res.type('application/xml');
|
|
389
|
+
return res.send(generateSitemapXml(cachedUrls, {
|
|
390
|
+
hostname,
|
|
391
|
+
defaultChangefreq: changefreq,
|
|
392
|
+
defaultPriority: priority
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Build URLs
|
|
397
|
+
const urls = await buildAllUrls(ctx.routes, dbInstance);
|
|
398
|
+
|
|
399
|
+
// Cache results
|
|
400
|
+
cachedUrls = urls;
|
|
401
|
+
cacheTime = now;
|
|
402
|
+
|
|
403
|
+
res.type('application/xml');
|
|
404
|
+
res.send(generateSitemapXml(urls, {
|
|
405
|
+
hostname,
|
|
406
|
+
defaultChangefreq: changefreq,
|
|
407
|
+
defaultPriority: priority
|
|
195
408
|
}));
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.error('Sitemap generation error:', err);
|
|
411
|
+
res.status(500).send('Error generating sitemap');
|
|
196
412
|
}
|
|
197
|
-
|
|
198
|
-
urls.push(urlEntry);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Register sitemap route
|
|
202
|
-
ctx.addRoute('get', '/sitemap.xml', (req, res) => {
|
|
203
|
-
res.type('application/xml');
|
|
204
|
-
res.send(generateSitemapXml(urls, { hostname, defaultChangefreq: changefreq, defaultPriority: priority }));
|
|
205
413
|
});
|
|
206
414
|
|
|
207
415
|
// Register robots.txt route
|
|
@@ -215,6 +423,13 @@ function sitemapPlugin(options = {}) {
|
|
|
215
423
|
};
|
|
216
424
|
}
|
|
217
425
|
|
|
426
|
+
// Export helpers for testing
|
|
427
|
+
sitemapPlugin.escapeXml = escapeXml;
|
|
428
|
+
sitemapPlugin.formatLastmod = formatLastmod;
|
|
429
|
+
sitemapPlugin.buildUrlFromPattern = buildUrlFromPattern;
|
|
430
|
+
sitemapPlugin.generateSitemapXml = generateSitemapXml;
|
|
431
|
+
sitemapPlugin.generateRobotsTxt = generateRobotsTxt;
|
|
432
|
+
|
|
218
433
|
module.exports = sitemapPlugin;
|
|
219
434
|
|
|
220
435
|
|
package/src/helpers.js
CHANGED
|
@@ -581,10 +581,379 @@ function createHelpers(ctx) {
|
|
|
581
581
|
dateEndOf(date, unit = 'day') {
|
|
582
582
|
if (!date) return dayjs();
|
|
583
583
|
return dayjs(date).endOf(unit);
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// ============================================
|
|
587
|
+
// Script Injection Helpers
|
|
588
|
+
// ============================================
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Get injected head content
|
|
592
|
+
* @returns {string} HTML content for head section
|
|
593
|
+
*/
|
|
594
|
+
injectHead() {
|
|
595
|
+
const injector = getScriptInjector();
|
|
596
|
+
let content = '';
|
|
597
|
+
|
|
598
|
+
// Add styles
|
|
599
|
+
const styles = injector.getStylesContent();
|
|
600
|
+
if (styles) {
|
|
601
|
+
content += `<style id="webspresso-injected-styles">\n${styles}\n</style>\n`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Add head scripts
|
|
605
|
+
content += injector.getHeadContent();
|
|
606
|
+
|
|
607
|
+
return content;
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Get injected body content (for end of body)
|
|
612
|
+
* @returns {string} HTML content for body end
|
|
613
|
+
*/
|
|
614
|
+
injectBody() {
|
|
615
|
+
return getScriptInjector().getBodyContent();
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Get dev toolbar HTML (only in development mode)
|
|
620
|
+
* @param {Object} options - { plugins: Array, customLinks: Array }
|
|
621
|
+
* @returns {string} Dev toolbar HTML
|
|
622
|
+
*/
|
|
623
|
+
devToolbar(options = {}) {
|
|
624
|
+
const injector = getScriptInjector();
|
|
625
|
+
const registeredPlugins = injector.getPlugins();
|
|
626
|
+
return generateDevToolbar({
|
|
627
|
+
...options,
|
|
628
|
+
plugins: [...registeredPlugins, ...(options.plugins || [])]
|
|
629
|
+
});
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Get the script injector instance for advanced usage
|
|
634
|
+
* @returns {ScriptInjector}
|
|
635
|
+
*/
|
|
636
|
+
getInjector() {
|
|
637
|
+
return getScriptInjector();
|
|
584
638
|
}
|
|
585
639
|
};
|
|
586
640
|
}
|
|
587
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Script Injector - manages script/style injection for templates
|
|
644
|
+
*/
|
|
645
|
+
class ScriptInjector {
|
|
646
|
+
constructor() {
|
|
647
|
+
this.headScripts = [];
|
|
648
|
+
this.bodyScripts = [];
|
|
649
|
+
this.styles = [];
|
|
650
|
+
this.plugins = [];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Add content to head section
|
|
655
|
+
* @param {string} content - HTML/script content
|
|
656
|
+
* @param {Object} options - { priority: number, id: string }
|
|
657
|
+
*/
|
|
658
|
+
addHead(content, options = {}) {
|
|
659
|
+
this.headScripts.push({
|
|
660
|
+
content,
|
|
661
|
+
priority: options.priority || 0,
|
|
662
|
+
id: options.id || null
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Add content to body end section
|
|
668
|
+
* @param {string} content - HTML/script content
|
|
669
|
+
* @param {Object} options - { priority: number, id: string }
|
|
670
|
+
*/
|
|
671
|
+
addBody(content, options = {}) {
|
|
672
|
+
this.bodyScripts.push({
|
|
673
|
+
content,
|
|
674
|
+
priority: options.priority || 0,
|
|
675
|
+
id: options.id || null
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Add CSS styles
|
|
681
|
+
* @param {string} css - CSS content
|
|
682
|
+
* @param {Object} options - { id: string }
|
|
683
|
+
*/
|
|
684
|
+
addStyle(css, options = {}) {
|
|
685
|
+
this.styles.push({
|
|
686
|
+
content: css,
|
|
687
|
+
id: options.id || null
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Register a plugin for dev toolbar
|
|
693
|
+
* @param {Object} plugin - { name, path, icon, description }
|
|
694
|
+
*/
|
|
695
|
+
registerPlugin(plugin) {
|
|
696
|
+
this.plugins.push(plugin);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Get head content sorted by priority
|
|
701
|
+
*/
|
|
702
|
+
getHeadContent() {
|
|
703
|
+
const sorted = [...this.headScripts].sort((a, b) => b.priority - a.priority);
|
|
704
|
+
return sorted.map(s => s.content).join('\n');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Get body content sorted by priority
|
|
709
|
+
*/
|
|
710
|
+
getBodyContent() {
|
|
711
|
+
const sorted = [...this.bodyScripts].sort((a, b) => b.priority - a.priority);
|
|
712
|
+
return sorted.map(s => s.content).join('\n');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Get styles content
|
|
717
|
+
*/
|
|
718
|
+
getStylesContent() {
|
|
719
|
+
return this.styles.map(s => s.content).join('\n');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Get registered plugins
|
|
724
|
+
*/
|
|
725
|
+
getPlugins() {
|
|
726
|
+
return [...this.plugins];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Clear all injections
|
|
731
|
+
*/
|
|
732
|
+
clear() {
|
|
733
|
+
this.headScripts = [];
|
|
734
|
+
this.bodyScripts = [];
|
|
735
|
+
this.styles = [];
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Global script injector instance
|
|
740
|
+
let globalInjector = new ScriptInjector();
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Get or create the global script injector
|
|
744
|
+
*/
|
|
745
|
+
function getScriptInjector() {
|
|
746
|
+
return globalInjector;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Reset the global script injector (useful for testing)
|
|
751
|
+
*/
|
|
752
|
+
function resetScriptInjector() {
|
|
753
|
+
globalInjector = new ScriptInjector();
|
|
754
|
+
return globalInjector;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Generate dev toolbar HTML
|
|
759
|
+
* @param {Object} options - { plugins: Array, routes: Array }
|
|
760
|
+
*/
|
|
761
|
+
function generateDevToolbar(options = {}) {
|
|
762
|
+
const { plugins = [], customLinks = [] } = options;
|
|
763
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
764
|
+
|
|
765
|
+
if (!isDev) return '';
|
|
766
|
+
|
|
767
|
+
// Default plugin links
|
|
768
|
+
const defaultPlugins = [
|
|
769
|
+
{ name: 'Dashboard', path: '/_webspresso', icon: '📊', description: 'Development Dashboard' },
|
|
770
|
+
{ name: 'Admin', path: '/_admin', icon: '⚙️', description: 'Admin Panel' },
|
|
771
|
+
{ name: 'Schema', path: '/_schema', icon: '🗂️', description: 'Schema Explorer' },
|
|
772
|
+
];
|
|
773
|
+
|
|
774
|
+
const allPlugins = [...defaultPlugins, ...plugins, ...customLinks];
|
|
775
|
+
|
|
776
|
+
const toolbarStyles = `
|
|
777
|
+
<style id="webspresso-dev-toolbar-styles">
|
|
778
|
+
#webspresso-dev-toolbar {
|
|
779
|
+
position: fixed;
|
|
780
|
+
bottom: 0;
|
|
781
|
+
left: 0;
|
|
782
|
+
right: 0;
|
|
783
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
784
|
+
border-top: 1px solid #0f3460;
|
|
785
|
+
padding: 0;
|
|
786
|
+
z-index: 99999;
|
|
787
|
+
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
|
788
|
+
font-size: 12px;
|
|
789
|
+
transform: translateY(calc(100% - 32px));
|
|
790
|
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
791
|
+
}
|
|
792
|
+
#webspresso-dev-toolbar:hover,
|
|
793
|
+
#webspresso-dev-toolbar.expanded {
|
|
794
|
+
transform: translateY(0);
|
|
795
|
+
}
|
|
796
|
+
#webspresso-dev-toolbar-toggle {
|
|
797
|
+
position: absolute;
|
|
798
|
+
top: -24px;
|
|
799
|
+
left: 50%;
|
|
800
|
+
transform: translateX(-50%);
|
|
801
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
802
|
+
border: 1px solid #0f3460;
|
|
803
|
+
border-bottom: none;
|
|
804
|
+
border-radius: 8px 8px 0 0;
|
|
805
|
+
padding: 4px 16px;
|
|
806
|
+
cursor: pointer;
|
|
807
|
+
color: #e94560;
|
|
808
|
+
font-weight: 600;
|
|
809
|
+
font-size: 10px;
|
|
810
|
+
letter-spacing: 1px;
|
|
811
|
+
text-transform: uppercase;
|
|
812
|
+
}
|
|
813
|
+
#webspresso-dev-toolbar-header {
|
|
814
|
+
display: flex;
|
|
815
|
+
align-items: center;
|
|
816
|
+
justify-content: space-between;
|
|
817
|
+
padding: 8px 16px;
|
|
818
|
+
border-bottom: 1px solid #0f3460;
|
|
819
|
+
background: rgba(0,0,0,0.2);
|
|
820
|
+
}
|
|
821
|
+
#webspresso-dev-toolbar-brand {
|
|
822
|
+
display: flex;
|
|
823
|
+
align-items: center;
|
|
824
|
+
gap: 8px;
|
|
825
|
+
color: #e94560;
|
|
826
|
+
font-weight: 700;
|
|
827
|
+
font-size: 11px;
|
|
828
|
+
letter-spacing: 0.5px;
|
|
829
|
+
}
|
|
830
|
+
#webspresso-dev-toolbar-brand svg {
|
|
831
|
+
width: 16px;
|
|
832
|
+
height: 16px;
|
|
833
|
+
}
|
|
834
|
+
#webspresso-dev-toolbar-info {
|
|
835
|
+
color: #a1a1aa;
|
|
836
|
+
font-size: 10px;
|
|
837
|
+
}
|
|
838
|
+
#webspresso-dev-toolbar-content {
|
|
839
|
+
display: flex;
|
|
840
|
+
align-items: stretch;
|
|
841
|
+
gap: 0;
|
|
842
|
+
padding: 0;
|
|
843
|
+
overflow-x: auto;
|
|
844
|
+
}
|
|
845
|
+
.webspresso-dev-toolbar-section {
|
|
846
|
+
display: flex;
|
|
847
|
+
align-items: center;
|
|
848
|
+
gap: 4px;
|
|
849
|
+
padding: 12px 16px;
|
|
850
|
+
border-right: 1px solid #0f3460;
|
|
851
|
+
}
|
|
852
|
+
.webspresso-dev-toolbar-section:last-child {
|
|
853
|
+
border-right: none;
|
|
854
|
+
}
|
|
855
|
+
.webspresso-dev-toolbar-section-title {
|
|
856
|
+
color: #a1a1aa;
|
|
857
|
+
font-size: 9px;
|
|
858
|
+
text-transform: uppercase;
|
|
859
|
+
letter-spacing: 1px;
|
|
860
|
+
margin-right: 8px;
|
|
861
|
+
}
|
|
862
|
+
.webspresso-dev-toolbar-link {
|
|
863
|
+
display: flex;
|
|
864
|
+
align-items: center;
|
|
865
|
+
gap: 6px;
|
|
866
|
+
padding: 6px 12px;
|
|
867
|
+
background: rgba(233, 69, 96, 0.1);
|
|
868
|
+
border: 1px solid rgba(233, 69, 96, 0.2);
|
|
869
|
+
border-radius: 6px;
|
|
870
|
+
color: #f1f1f1;
|
|
871
|
+
text-decoration: none;
|
|
872
|
+
transition: all 0.2s ease;
|
|
873
|
+
white-space: nowrap;
|
|
874
|
+
}
|
|
875
|
+
.webspresso-dev-toolbar-link:hover {
|
|
876
|
+
background: rgba(233, 69, 96, 0.2);
|
|
877
|
+
border-color: #e94560;
|
|
878
|
+
color: #fff;
|
|
879
|
+
transform: translateY(-1px);
|
|
880
|
+
}
|
|
881
|
+
.webspresso-dev-toolbar-link-icon {
|
|
882
|
+
font-size: 14px;
|
|
883
|
+
}
|
|
884
|
+
.webspresso-dev-toolbar-link-text {
|
|
885
|
+
font-weight: 500;
|
|
886
|
+
}
|
|
887
|
+
.webspresso-dev-toolbar-close {
|
|
888
|
+
background: none;
|
|
889
|
+
border: none;
|
|
890
|
+
color: #a1a1aa;
|
|
891
|
+
cursor: pointer;
|
|
892
|
+
padding: 4px 8px;
|
|
893
|
+
font-size: 16px;
|
|
894
|
+
transition: color 0.2s;
|
|
895
|
+
}
|
|
896
|
+
.webspresso-dev-toolbar-close:hover {
|
|
897
|
+
color: #e94560;
|
|
898
|
+
}
|
|
899
|
+
@media (max-width: 768px) {
|
|
900
|
+
#webspresso-dev-toolbar {
|
|
901
|
+
font-size: 11px;
|
|
902
|
+
}
|
|
903
|
+
.webspresso-dev-toolbar-link {
|
|
904
|
+
padding: 4px 8px;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
</style>
|
|
908
|
+
`;
|
|
909
|
+
|
|
910
|
+
const pluginLinks = allPlugins.map(p => `
|
|
911
|
+
<a href="${p.path}" class="webspresso-dev-toolbar-link" title="${p.description || p.name}">
|
|
912
|
+
<span class="webspresso-dev-toolbar-link-icon">${p.icon || '🔗'}</span>
|
|
913
|
+
<span class="webspresso-dev-toolbar-link-text">${p.name}</span>
|
|
914
|
+
</a>
|
|
915
|
+
`).join('');
|
|
916
|
+
|
|
917
|
+
const toolbarHtml = `
|
|
918
|
+
${toolbarStyles}
|
|
919
|
+
<div id="webspresso-dev-toolbar">
|
|
920
|
+
<div id="webspresso-dev-toolbar-toggle">⚡ DEV</div>
|
|
921
|
+
<div id="webspresso-dev-toolbar-header">
|
|
922
|
+
<div id="webspresso-dev-toolbar-brand">
|
|
923
|
+
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
924
|
+
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" opacity="0.3"/>
|
|
925
|
+
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
926
|
+
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
927
|
+
</svg>
|
|
928
|
+
WEBSPRESSO DEV
|
|
929
|
+
</div>
|
|
930
|
+
<div id="webspresso-dev-toolbar-info">
|
|
931
|
+
Node ${process.version} | ${new Date().toLocaleTimeString()}
|
|
932
|
+
</div>
|
|
933
|
+
</div>
|
|
934
|
+
<div id="webspresso-dev-toolbar-content">
|
|
935
|
+
<div class="webspresso-dev-toolbar-section">
|
|
936
|
+
<span class="webspresso-dev-toolbar-section-title">Quick Links</span>
|
|
937
|
+
${pluginLinks}
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
<script>
|
|
942
|
+
(function() {
|
|
943
|
+
const toolbar = document.getElementById('webspresso-dev-toolbar');
|
|
944
|
+
const toggle = document.getElementById('webspresso-dev-toolbar-toggle');
|
|
945
|
+
if (toolbar && toggle) {
|
|
946
|
+
toggle.addEventListener('click', function() {
|
|
947
|
+
toolbar.classList.toggle('expanded');
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
})();
|
|
951
|
+
</script>
|
|
952
|
+
`;
|
|
953
|
+
|
|
954
|
+
return toolbarHtml;
|
|
955
|
+
}
|
|
956
|
+
|
|
588
957
|
/**
|
|
589
958
|
* Pure utility functions (can be used without request context)
|
|
590
959
|
*/
|
|
@@ -637,6 +1006,10 @@ module.exports = {
|
|
|
637
1006
|
utils,
|
|
638
1007
|
AssetManager,
|
|
639
1008
|
configureAssets,
|
|
640
|
-
getAssetManager
|
|
1009
|
+
getAssetManager,
|
|
1010
|
+
ScriptInjector,
|
|
1011
|
+
getScriptInjector,
|
|
1012
|
+
resetScriptInjector,
|
|
1013
|
+
generateDevToolbar
|
|
641
1014
|
};
|
|
642
1015
|
|
package/src/plugin-manager.js
CHANGED
|
@@ -333,6 +333,8 @@ class PluginManager {
|
|
|
333
333
|
*/
|
|
334
334
|
_createPluginContext(plugin, context) {
|
|
335
335
|
const self = this;
|
|
336
|
+
// Import script injector
|
|
337
|
+
const { getScriptInjector } = require('./helpers');
|
|
336
338
|
|
|
337
339
|
return {
|
|
338
340
|
app: context.app,
|
|
@@ -372,6 +374,52 @@ class PluginManager {
|
|
|
372
374
|
*/
|
|
373
375
|
get routes() {
|
|
374
376
|
return self.routes;
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// ============================================
|
|
380
|
+
// Script Injection API
|
|
381
|
+
// ============================================
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Inject content into head section
|
|
385
|
+
* @param {string} content - HTML/script content
|
|
386
|
+
* @param {Object} options - { priority: number, id: string }
|
|
387
|
+
*/
|
|
388
|
+
injectHead(content, options = {}) {
|
|
389
|
+
getScriptInjector().addHead(content, options);
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Inject content into body end section
|
|
394
|
+
* @param {string} content - HTML/script content
|
|
395
|
+
* @param {Object} options - { priority: number, id: string }
|
|
396
|
+
*/
|
|
397
|
+
injectBody(content, options = {}) {
|
|
398
|
+
getScriptInjector().addBody(content, options);
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Inject CSS styles
|
|
403
|
+
* @param {string} css - CSS content
|
|
404
|
+
* @param {Object} options - { id: string }
|
|
405
|
+
*/
|
|
406
|
+
injectStyle(css, options = {}) {
|
|
407
|
+
getScriptInjector().addStyle(css, options);
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Register a link for the dev toolbar
|
|
412
|
+
* @param {Object} link - { name, path, icon, description }
|
|
413
|
+
*/
|
|
414
|
+
registerDevLink(link) {
|
|
415
|
+
getScriptInjector().registerPlugin(link);
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get the script injector instance for advanced usage
|
|
420
|
+
*/
|
|
421
|
+
getScriptInjector() {
|
|
422
|
+
return getScriptInjector();
|
|
375
423
|
}
|
|
376
424
|
};
|
|
377
425
|
}
|