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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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, '&apos;');
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: '1.0.0',
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
- * Get all sitemap URLs
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
- // Filter routes for sitemap
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
- for (const route of sitemapRoutes) {
163
- const baseUrl = `${hostname.replace(/\/$/, '')}${route.pattern}`;
164
-
165
- const urlEntry = {
166
- loc: baseUrl,
167
- changefreq,
168
- priority
169
- };
170
-
171
- // Add i18n alternates
172
- if (i18n && locales.length > 1) {
173
- urlEntry.alternates = locales.map(lang => ({
174
- lang,
175
- href: `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}lang=${lang}`
176
- }));
177
- }
178
-
179
- urls.push(urlEntry);
180
- }
181
-
182
- // Add dynamic URLs
183
- for (const dynUrl of dynamicUrls) {
184
- const urlEntry = {
185
- loc: `${hostname.replace(/\/$/, '')}${dynUrl.path}`,
186
- changefreq: dynUrl.changefreq || changefreq,
187
- priority: dynUrl.priority || priority,
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
 
@@ -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
  }