webspresso 0.0.3 → 0.0.4
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 +134 -1
- package/index.js +13 -1
- package/package.json +1 -1
- package/src/file-router.js +28 -3
- package/src/plugin-manager.js +451 -0
- package/src/server.js +37 -3
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
|
10
10
|
- **Built-in i18n**: JSON-based translations with automatic locale detection
|
|
11
11
|
- **Lifecycle Hooks**: Global and route-level hooks for request processing
|
|
12
12
|
- **Template Helpers**: Laravel-inspired helper functions available in templates
|
|
13
|
+
- **Plugin System**: Extensible architecture with version control and inter-plugin communication
|
|
14
|
+
- **Built-in Plugins**: Sitemap generator, analytics integration (Google, Yandex, Bing)
|
|
13
15
|
|
|
14
16
|
## Installation
|
|
15
17
|
|
|
@@ -313,7 +315,138 @@ Works with Vite and Webpack manifest formats:
|
|
|
313
315
|
}
|
|
314
316
|
```
|
|
315
317
|
|
|
316
|
-
**Returns:** `{ app, nunjucksEnv }`
|
|
318
|
+
**Returns:** `{ app, nunjucksEnv, pluginManager }`
|
|
319
|
+
|
|
320
|
+
## Plugin System
|
|
321
|
+
|
|
322
|
+
Webspresso has a built-in plugin system with version control and dependency management.
|
|
323
|
+
|
|
324
|
+
### Using Plugins
|
|
325
|
+
|
|
326
|
+
```javascript
|
|
327
|
+
const { createApp } = require('webspresso');
|
|
328
|
+
const { sitemapPlugin, analyticsPlugin } = require('webspresso/plugins');
|
|
329
|
+
|
|
330
|
+
const { app } = createApp({
|
|
331
|
+
pagesDir: './pages',
|
|
332
|
+
viewsDir: './views',
|
|
333
|
+
plugins: [
|
|
334
|
+
sitemapPlugin({
|
|
335
|
+
hostname: 'https://example.com',
|
|
336
|
+
exclude: ['/admin/*', '/api/*'],
|
|
337
|
+
i18n: true,
|
|
338
|
+
locales: ['en', 'tr']
|
|
339
|
+
}),
|
|
340
|
+
analyticsPlugin({
|
|
341
|
+
google: {
|
|
342
|
+
measurementId: 'G-XXXXXXXXXX',
|
|
343
|
+
verificationCode: 'xxxxx'
|
|
344
|
+
},
|
|
345
|
+
yandex: {
|
|
346
|
+
counterId: '12345678',
|
|
347
|
+
verificationCode: 'xxxxx'
|
|
348
|
+
},
|
|
349
|
+
bing: {
|
|
350
|
+
uetId: '12345678',
|
|
351
|
+
verificationCode: 'xxxxx'
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
]
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Built-in Plugins
|
|
359
|
+
|
|
360
|
+
**Sitemap Plugin:**
|
|
361
|
+
- Generates `/sitemap.xml` from routes automatically
|
|
362
|
+
- Excludes dynamic routes and API endpoints
|
|
363
|
+
- Supports i18n with hreflang tags
|
|
364
|
+
- Generates `/robots.txt`
|
|
365
|
+
|
|
366
|
+
**Analytics Plugin:**
|
|
367
|
+
- Google Analytics (GA4) and Google Ads
|
|
368
|
+
- Google Tag Manager
|
|
369
|
+
- Yandex.Metrika
|
|
370
|
+
- Microsoft/Bing UET
|
|
371
|
+
- Facebook Pixel
|
|
372
|
+
- Verification meta tags for all services
|
|
373
|
+
|
|
374
|
+
Template helpers from analytics plugin:
|
|
375
|
+
|
|
376
|
+
```njk
|
|
377
|
+
<head>
|
|
378
|
+
{{ fsy.verificationTags() | safe }}
|
|
379
|
+
{{ fsy.analyticsHead() | safe }}
|
|
380
|
+
</head>
|
|
381
|
+
<body>
|
|
382
|
+
{{ fsy.analyticsBodyOpen() | safe }}
|
|
383
|
+
...
|
|
384
|
+
</body>
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Individual helpers: `gtag()`, `gtm()`, `gtmNoscript()`, `yandexMetrika()`, `bingUET()`, `facebookPixel()`, `allAnalytics()`
|
|
388
|
+
|
|
389
|
+
### Creating Custom Plugins
|
|
390
|
+
|
|
391
|
+
```javascript
|
|
392
|
+
const myPlugin = {
|
|
393
|
+
name: 'my-plugin',
|
|
394
|
+
version: '1.0.0',
|
|
395
|
+
|
|
396
|
+
// Optional: depend on other plugins
|
|
397
|
+
dependencies: {
|
|
398
|
+
'analytics': '^1.0.0'
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
// Optional: expose API for other plugins
|
|
402
|
+
api: {
|
|
403
|
+
getData() { return this.data; }
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// Called during registration
|
|
407
|
+
register(ctx) {
|
|
408
|
+
// Access Express app
|
|
409
|
+
ctx.app.use((req, res, next) => next());
|
|
410
|
+
|
|
411
|
+
// Add template helpers
|
|
412
|
+
ctx.addHelper('myHelper', () => 'Hello!');
|
|
413
|
+
|
|
414
|
+
// Add Nunjucks filters
|
|
415
|
+
ctx.addFilter('myFilter', (val) => val.toUpperCase());
|
|
416
|
+
|
|
417
|
+
// Use other plugins
|
|
418
|
+
const analytics = ctx.usePlugin('analytics');
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
// Called after all routes are mounted
|
|
422
|
+
onRoutesReady(ctx) {
|
|
423
|
+
// Access route metadata
|
|
424
|
+
console.log('Routes:', ctx.routes);
|
|
425
|
+
|
|
426
|
+
// Add custom routes
|
|
427
|
+
ctx.addRoute('get', '/my-route', (req, res) => {
|
|
428
|
+
res.json({ hello: 'world' });
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
// Called before server starts
|
|
433
|
+
onReady(ctx) {
|
|
434
|
+
console.log('Server ready!');
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// Use as factory function for configuration
|
|
439
|
+
function myPluginFactory(options = {}) {
|
|
440
|
+
return {
|
|
441
|
+
name: 'my-plugin',
|
|
442
|
+
version: '1.0.0',
|
|
443
|
+
_options: options,
|
|
444
|
+
register(ctx) {
|
|
445
|
+
// ctx.options contains the passed options
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
```
|
|
317
450
|
|
|
318
451
|
## File-Based Routing
|
|
319
452
|
|
package/index.js
CHANGED
|
@@ -19,6 +19,12 @@ const {
|
|
|
19
19
|
configureAssets,
|
|
20
20
|
getAssetManager
|
|
21
21
|
} = require('./src/helpers');
|
|
22
|
+
const {
|
|
23
|
+
PluginManager,
|
|
24
|
+
createPluginManager,
|
|
25
|
+
getPluginManager,
|
|
26
|
+
resetPluginManager
|
|
27
|
+
} = require('./src/plugin-manager');
|
|
22
28
|
|
|
23
29
|
module.exports = {
|
|
24
30
|
// Main API
|
|
@@ -40,6 +46,12 @@ module.exports = {
|
|
|
40
46
|
// Asset management
|
|
41
47
|
AssetManager,
|
|
42
48
|
configureAssets,
|
|
43
|
-
getAssetManager
|
|
49
|
+
getAssetManager,
|
|
50
|
+
|
|
51
|
+
// Plugin system
|
|
52
|
+
PluginManager,
|
|
53
|
+
createPluginManager,
|
|
54
|
+
getPluginManager,
|
|
55
|
+
resetPluginManager
|
|
44
56
|
};
|
|
45
57
|
|
package/package.json
CHANGED
package/src/file-router.js
CHANGED
|
@@ -310,10 +310,12 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
|
310
310
|
* @param {string} options.pagesDir - Pages directory path
|
|
311
311
|
* @param {Object} options.nunjucks - Nunjucks environment
|
|
312
312
|
* @param {Object} options.middlewares - Named middleware registry
|
|
313
|
+
* @param {Object} options.pluginManager - Plugin manager instance
|
|
313
314
|
* @param {boolean} options.silent - Suppress console output
|
|
315
|
+
* @returns {Array} Route metadata for plugins
|
|
314
316
|
*/
|
|
315
317
|
function mountPages(app, options) {
|
|
316
|
-
const { pagesDir, nunjucks, middlewares = {}, silent = false } = options;
|
|
318
|
+
const { pagesDir, nunjucks, middlewares = {}, pluginManager = null, silent = false } = options;
|
|
317
319
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
318
320
|
const log = silent ? () => {} : console.log.bind(console);
|
|
319
321
|
|
|
@@ -444,7 +446,10 @@ function mountPages(app, options) {
|
|
|
444
446
|
const config = loadRouteConfig(route.configPath, isDev);
|
|
445
447
|
const routeHooks = config?.hooks || {};
|
|
446
448
|
|
|
447
|
-
// Create context
|
|
449
|
+
// Create context with plugin helpers merged
|
|
450
|
+
const baseHelpers = createHelpers({ req, res, locale });
|
|
451
|
+
const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
|
|
452
|
+
|
|
448
453
|
const ctx = {
|
|
449
454
|
req,
|
|
450
455
|
res,
|
|
@@ -460,7 +465,7 @@ function mountPages(app, options) {
|
|
|
460
465
|
indexable: true,
|
|
461
466
|
canonical: null
|
|
462
467
|
},
|
|
463
|
-
fsy:
|
|
468
|
+
fsy: { ...baseHelpers, ...pluginHelpers }
|
|
464
469
|
};
|
|
465
470
|
|
|
466
471
|
// Execute hooks: onRequest
|
|
@@ -554,6 +559,26 @@ function mountPages(app, options) {
|
|
|
554
559
|
|
|
555
560
|
log(` GET ${route.routePath} -> ${route.file}`);
|
|
556
561
|
}
|
|
562
|
+
|
|
563
|
+
// Return route metadata for plugins
|
|
564
|
+
const routeMetadata = [
|
|
565
|
+
...ssrRoutes.map(r => ({
|
|
566
|
+
type: 'ssr',
|
|
567
|
+
method: 'get',
|
|
568
|
+
pattern: r.routePath,
|
|
569
|
+
file: r.file,
|
|
570
|
+
isDynamic: r.routePath.includes(':') || r.routePath.includes('*')
|
|
571
|
+
})),
|
|
572
|
+
...apiRoutes.map(r => ({
|
|
573
|
+
type: 'api',
|
|
574
|
+
method: r.method,
|
|
575
|
+
pattern: r.routePath,
|
|
576
|
+
file: r.file,
|
|
577
|
+
isDynamic: r.routePath.includes(':') || r.routePath.includes('*')
|
|
578
|
+
}))
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
return routeMetadata;
|
|
557
582
|
}
|
|
558
583
|
|
|
559
584
|
module.exports = {
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Plugin Manager
|
|
3
|
+
* Handles plugin registration, lifecycle, dependencies, and inter-plugin communication
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Simple semver comparison utilities
|
|
8
|
+
* (Lightweight alternative to full semver package)
|
|
9
|
+
*/
|
|
10
|
+
const semver = {
|
|
11
|
+
/**
|
|
12
|
+
* Parse version string to components
|
|
13
|
+
*/
|
|
14
|
+
parse(version) {
|
|
15
|
+
if (!version) return null;
|
|
16
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
return {
|
|
19
|
+
major: parseInt(match[1], 10),
|
|
20
|
+
minor: parseInt(match[2], 10),
|
|
21
|
+
patch: parseInt(match[3], 10),
|
|
22
|
+
prerelease: match[4] || null
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compare two versions
|
|
28
|
+
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
|
|
29
|
+
*/
|
|
30
|
+
compare(a, b) {
|
|
31
|
+
const va = this.parse(a);
|
|
32
|
+
const vb = this.parse(b);
|
|
33
|
+
if (!va || !vb) return 0;
|
|
34
|
+
|
|
35
|
+
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
|
|
36
|
+
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
|
|
37
|
+
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
|
|
38
|
+
return 0;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if version satisfies a range
|
|
43
|
+
* Supports: ^1.0.0, ~1.0.0, >=1.0.0, >1.0.0, <=1.0.0, <1.0.0, 1.0.0, *
|
|
44
|
+
*/
|
|
45
|
+
satisfies(version, range) {
|
|
46
|
+
if (!range || range === '*') return true;
|
|
47
|
+
|
|
48
|
+
const v = this.parse(version);
|
|
49
|
+
if (!v) return false;
|
|
50
|
+
|
|
51
|
+
// Handle caret (^) - compatible with major version
|
|
52
|
+
if (range.startsWith('^')) {
|
|
53
|
+
const r = this.parse(range.slice(1));
|
|
54
|
+
if (!r) return false;
|
|
55
|
+
if (v.major !== r.major) return false;
|
|
56
|
+
if (v.major === 0) {
|
|
57
|
+
// For 0.x.x, minor must match
|
|
58
|
+
if (v.minor !== r.minor) return false;
|
|
59
|
+
return v.patch >= r.patch;
|
|
60
|
+
}
|
|
61
|
+
return this.compare(version, range.slice(1)) >= 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle tilde (~) - compatible with minor version
|
|
65
|
+
if (range.startsWith('~')) {
|
|
66
|
+
const r = this.parse(range.slice(1));
|
|
67
|
+
if (!r) return false;
|
|
68
|
+
return v.major === r.major && v.minor === r.minor && v.patch >= r.patch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle >=
|
|
72
|
+
if (range.startsWith('>=')) {
|
|
73
|
+
return this.compare(version, range.slice(2)) >= 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle >
|
|
77
|
+
if (range.startsWith('>') && !range.startsWith('>=')) {
|
|
78
|
+
return this.compare(version, range.slice(1)) > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle <=
|
|
82
|
+
if (range.startsWith('<=')) {
|
|
83
|
+
return this.compare(version, range.slice(2)) <= 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle <
|
|
87
|
+
if (range.startsWith('<') && !range.startsWith('<=')) {
|
|
88
|
+
return this.compare(version, range.slice(1)) < 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Exact match
|
|
92
|
+
return this.compare(version, range) === 0;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Simple glob/minimatch pattern matching
|
|
98
|
+
*/
|
|
99
|
+
function matchPattern(path, pattern) {
|
|
100
|
+
if (pattern === '*' || pattern === '**') return true;
|
|
101
|
+
|
|
102
|
+
// Convert glob to regex
|
|
103
|
+
const regexPattern = pattern
|
|
104
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
|
105
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
|
|
106
|
+
.replace(/\*/g, '[^/]*') // * matches anything except /
|
|
107
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*') // ** matches everything
|
|
108
|
+
.replace(/\?/g, '.'); // ? matches single char
|
|
109
|
+
|
|
110
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
111
|
+
return regex.test(path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Plugin Manager Class
|
|
116
|
+
*/
|
|
117
|
+
class PluginManager {
|
|
118
|
+
constructor() {
|
|
119
|
+
this.plugins = new Map(); // name -> plugin instance
|
|
120
|
+
this.pluginAPIs = new Map(); // name -> exposed API
|
|
121
|
+
this.registeredHelpers = new Map(); // name -> helper function
|
|
122
|
+
this.registeredFilters = new Map(); // name -> filter function
|
|
123
|
+
this.routes = []; // Collected route metadata
|
|
124
|
+
this.customRoutes = []; // Routes added by plugins
|
|
125
|
+
this.app = null;
|
|
126
|
+
this.nunjucksEnv = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Register plugins with the manager
|
|
131
|
+
* @param {Array} plugins - Array of plugin definitions or factory functions
|
|
132
|
+
* @param {Object} context - Context object { app, nunjucksEnv, options }
|
|
133
|
+
*/
|
|
134
|
+
async register(plugins, context) {
|
|
135
|
+
if (!plugins || !Array.isArray(plugins)) return;
|
|
136
|
+
|
|
137
|
+
this.app = context.app;
|
|
138
|
+
this.nunjucksEnv = context.nunjucksEnv;
|
|
139
|
+
|
|
140
|
+
// Normalize plugins (handle factory functions)
|
|
141
|
+
const normalizedPlugins = plugins.map(p => {
|
|
142
|
+
if (typeof p === 'function') {
|
|
143
|
+
// Already a factory that was called
|
|
144
|
+
return p;
|
|
145
|
+
}
|
|
146
|
+
return p;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Validate and sort by dependencies
|
|
150
|
+
const sorted = this._resolveDependencyOrder(normalizedPlugins);
|
|
151
|
+
|
|
152
|
+
// Register each plugin in order
|
|
153
|
+
for (const plugin of sorted) {
|
|
154
|
+
await this._registerPlugin(plugin, context);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve dependency order using topological sort
|
|
160
|
+
*/
|
|
161
|
+
_resolveDependencyOrder(plugins) {
|
|
162
|
+
const graph = new Map();
|
|
163
|
+
const pluginMap = new Map();
|
|
164
|
+
|
|
165
|
+
// Build graph
|
|
166
|
+
for (const plugin of plugins) {
|
|
167
|
+
if (!plugin.name) {
|
|
168
|
+
throw new Error('Plugin must have a name');
|
|
169
|
+
}
|
|
170
|
+
if (pluginMap.has(plugin.name)) {
|
|
171
|
+
throw new Error(`Duplicate plugin name: ${plugin.name}`);
|
|
172
|
+
}
|
|
173
|
+
pluginMap.set(plugin.name, plugin);
|
|
174
|
+
graph.set(plugin.name, new Set(Object.keys(plugin.dependencies || {})));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Topological sort (Kahn's algorithm)
|
|
178
|
+
const sorted = [];
|
|
179
|
+
const noIncoming = [];
|
|
180
|
+
|
|
181
|
+
// Find nodes with no dependencies
|
|
182
|
+
for (const [name, deps] of graph) {
|
|
183
|
+
// Filter to only include dependencies that are in our plugin list
|
|
184
|
+
const relevantDeps = new Set([...deps].filter(d => pluginMap.has(d)));
|
|
185
|
+
graph.set(name, relevantDeps);
|
|
186
|
+
if (relevantDeps.size === 0) {
|
|
187
|
+
noIncoming.push(name);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
while (noIncoming.length > 0) {
|
|
192
|
+
const name = noIncoming.shift();
|
|
193
|
+
sorted.push(pluginMap.get(name));
|
|
194
|
+
|
|
195
|
+
// Remove this node from all dependencies
|
|
196
|
+
for (const [n, deps] of graph) {
|
|
197
|
+
if (deps.has(name)) {
|
|
198
|
+
deps.delete(name);
|
|
199
|
+
if (deps.size === 0 && !sorted.find(p => p.name === n)) {
|
|
200
|
+
noIncoming.push(n);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check for cycles
|
|
207
|
+
if (sorted.length !== plugins.length) {
|
|
208
|
+
const remaining = plugins.filter(p => !sorted.includes(p)).map(p => p.name);
|
|
209
|
+
throw new Error(`Circular dependency detected in plugins: ${remaining.join(', ')}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return sorted;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Register a single plugin
|
|
217
|
+
*/
|
|
218
|
+
async _registerPlugin(plugin, context) {
|
|
219
|
+
// Validate dependencies
|
|
220
|
+
this._validateDependencies(plugin);
|
|
221
|
+
|
|
222
|
+
// Create plugin context
|
|
223
|
+
const ctx = this._createPluginContext(plugin, context);
|
|
224
|
+
|
|
225
|
+
// Store plugin
|
|
226
|
+
this.plugins.set(plugin.name, plugin);
|
|
227
|
+
|
|
228
|
+
// Store API if provided
|
|
229
|
+
if (plugin.api) {
|
|
230
|
+
// Bind API methods to plugin instance
|
|
231
|
+
const boundAPI = {};
|
|
232
|
+
for (const [key, value] of Object.entries(plugin.api)) {
|
|
233
|
+
boundAPI[key] = typeof value === 'function' ? value.bind(plugin) : value;
|
|
234
|
+
}
|
|
235
|
+
this.pluginAPIs.set(plugin.name, boundAPI);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Call register hook
|
|
239
|
+
if (typeof plugin.register === 'function') {
|
|
240
|
+
await plugin.register(ctx);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Apply registered helpers to nunjucks
|
|
244
|
+
this._applyHelpersAndFilters();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Validate plugin dependencies
|
|
249
|
+
*/
|
|
250
|
+
_validateDependencies(plugin) {
|
|
251
|
+
if (!plugin.dependencies) return;
|
|
252
|
+
|
|
253
|
+
for (const [depName, versionRange] of Object.entries(plugin.dependencies)) {
|
|
254
|
+
const dep = this.plugins.get(depName);
|
|
255
|
+
|
|
256
|
+
if (!dep) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Plugin "${plugin.name}" requires "${depName}" but it's not loaded`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (dep.version && !semver.satisfies(dep.version, versionRange)) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Plugin "${plugin.name}" requires "${depName}@${versionRange}" ` +
|
|
265
|
+
`but found v${dep.version}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Create context object for plugin
|
|
273
|
+
*/
|
|
274
|
+
_createPluginContext(plugin, context) {
|
|
275
|
+
const self = this;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
app: context.app,
|
|
279
|
+
options: plugin._options || {},
|
|
280
|
+
nunjucksEnv: context.nunjucksEnv,
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get another plugin's API
|
|
284
|
+
*/
|
|
285
|
+
usePlugin(name) {
|
|
286
|
+
return self.pluginAPIs.get(name) || null;
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Add a template helper (available as fsy.helperName())
|
|
291
|
+
*/
|
|
292
|
+
addHelper(name, fn) {
|
|
293
|
+
self.registeredHelpers.set(name, fn);
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Add a Nunjucks filter
|
|
298
|
+
*/
|
|
299
|
+
addFilter(name, fn) {
|
|
300
|
+
self.registeredFilters.set(name, fn);
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Add a custom route
|
|
305
|
+
*/
|
|
306
|
+
addRoute(method, path, ...handlers) {
|
|
307
|
+
self.customRoutes.push({ method: method.toLowerCase(), path, handlers });
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get all registered routes (available after onRoutesReady)
|
|
312
|
+
*/
|
|
313
|
+
get routes() {
|
|
314
|
+
return self.routes;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Apply registered helpers and filters to Nunjucks
|
|
321
|
+
*/
|
|
322
|
+
_applyHelpersAndFilters() {
|
|
323
|
+
if (!this.nunjucksEnv) return;
|
|
324
|
+
|
|
325
|
+
// Add filters
|
|
326
|
+
for (const [name, fn] of this.registeredFilters) {
|
|
327
|
+
this.nunjucksEnv.addFilter(name, fn);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Get all registered helpers (to be merged with fsy)
|
|
333
|
+
*/
|
|
334
|
+
getHelpers() {
|
|
335
|
+
const helpers = {};
|
|
336
|
+
for (const [name, fn] of this.registeredHelpers) {
|
|
337
|
+
helpers[name] = fn;
|
|
338
|
+
}
|
|
339
|
+
return helpers;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Set route metadata (called by file-router after mounting)
|
|
344
|
+
*/
|
|
345
|
+
setRoutes(routes) {
|
|
346
|
+
this.routes = routes;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Mount custom routes added by plugins
|
|
351
|
+
*/
|
|
352
|
+
mountCustomRoutes() {
|
|
353
|
+
for (const { method, path, handlers } of this.customRoutes) {
|
|
354
|
+
if (this.app && this.app[method]) {
|
|
355
|
+
this.app[method](path, ...handlers);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Call onRoutesReady hook on all plugins
|
|
362
|
+
*/
|
|
363
|
+
async onRoutesReady(context) {
|
|
364
|
+
for (const [name, plugin] of this.plugins) {
|
|
365
|
+
if (typeof plugin.onRoutesReady === 'function') {
|
|
366
|
+
const ctx = this._createPluginContext(plugin, context);
|
|
367
|
+
await plugin.onRoutesReady(ctx);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Mount any routes added during onRoutesReady
|
|
372
|
+
this.mountCustomRoutes();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Call onReady hook on all plugins
|
|
377
|
+
*/
|
|
378
|
+
async onReady(context) {
|
|
379
|
+
for (const [name, plugin] of this.plugins) {
|
|
380
|
+
if (typeof plugin.onReady === 'function') {
|
|
381
|
+
const ctx = this._createPluginContext(plugin, context);
|
|
382
|
+
await plugin.onReady(ctx);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get a plugin by name
|
|
389
|
+
*/
|
|
390
|
+
getPlugin(name) {
|
|
391
|
+
return this.plugins.get(name);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Get a plugin's API by name
|
|
396
|
+
*/
|
|
397
|
+
getPluginAPI(name) {
|
|
398
|
+
return this.pluginAPIs.get(name);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Check if a plugin is registered
|
|
403
|
+
*/
|
|
404
|
+
hasPlugin(name) {
|
|
405
|
+
return this.plugins.has(name);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get all registered plugin names
|
|
410
|
+
*/
|
|
411
|
+
getPluginNames() {
|
|
412
|
+
return Array.from(this.plugins.keys());
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Singleton instance for global access
|
|
417
|
+
let globalPluginManager = null;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get or create the global plugin manager
|
|
421
|
+
*/
|
|
422
|
+
function getPluginManager() {
|
|
423
|
+
if (!globalPluginManager) {
|
|
424
|
+
globalPluginManager = new PluginManager();
|
|
425
|
+
}
|
|
426
|
+
return globalPluginManager;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Create a new plugin manager (for testing)
|
|
431
|
+
*/
|
|
432
|
+
function createPluginManager() {
|
|
433
|
+
return new PluginManager();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Reset the global plugin manager (for testing)
|
|
438
|
+
*/
|
|
439
|
+
function resetPluginManager() {
|
|
440
|
+
globalPluginManager = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = {
|
|
444
|
+
PluginManager,
|
|
445
|
+
getPluginManager,
|
|
446
|
+
createPluginManager,
|
|
447
|
+
resetPluginManager,
|
|
448
|
+
semver,
|
|
449
|
+
matchPattern
|
|
450
|
+
};
|
|
451
|
+
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,7 @@ const nunjucks = require('nunjucks');
|
|
|
9
9
|
|
|
10
10
|
const { mountPages } = require('./file-router');
|
|
11
11
|
const { configureAssets } = require('./helpers');
|
|
12
|
+
const { createPluginManager } = require('./plugin-manager');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Get default Helmet configuration
|
|
@@ -117,6 +118,7 @@ function default500Html(err, isDev) {
|
|
|
117
118
|
* @param {boolean} options.logging - Enable request logging (default: isDev)
|
|
118
119
|
* @param {Object|boolean} options.helmet - Helmet configuration (default: auto-configured, false to disable)
|
|
119
120
|
* @param {Object} options.middlewares - Named middleware registry for route configs
|
|
121
|
+
* @param {Array} options.plugins - Array of plugin definitions
|
|
120
122
|
* @param {Object} options.assets - Asset manager configuration
|
|
121
123
|
* @param {string} options.assets.manifestPath - Path to asset manifest file (Vite, Webpack)
|
|
122
124
|
* @param {string} options.assets.version - Asset version for cache busting
|
|
@@ -124,7 +126,7 @@ function default500Html(err, isDev) {
|
|
|
124
126
|
* @param {Object} options.errorPages - Custom error page handlers
|
|
125
127
|
* @param {Function|string} options.errorPages.notFound - Custom 404 handler or template path
|
|
126
128
|
* @param {Function|string} options.errorPages.serverError - Custom 500 handler or template path
|
|
127
|
-
* @returns {Object} { app, nunjucksEnv }
|
|
129
|
+
* @returns {Object} { app, nunjucksEnv, pluginManager }
|
|
128
130
|
*/
|
|
129
131
|
function createApp(options = {}) {
|
|
130
132
|
const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
@@ -138,10 +140,14 @@ function createApp(options = {}) {
|
|
|
138
140
|
logging = isDev && !isTest,
|
|
139
141
|
helmet: helmetConfig,
|
|
140
142
|
middlewares = {},
|
|
143
|
+
plugins = [],
|
|
141
144
|
assets: assetsConfig = {},
|
|
142
145
|
errorPages = {}
|
|
143
146
|
} = options;
|
|
144
147
|
|
|
148
|
+
// Create plugin manager
|
|
149
|
+
const pluginManager = createPluginManager();
|
|
150
|
+
|
|
145
151
|
// Configure asset manager
|
|
146
152
|
configureAssets({
|
|
147
153
|
publicDir: publicDir || 'public',
|
|
@@ -213,6 +219,10 @@ function createApp(options = {}) {
|
|
|
213
219
|
return d.toString();
|
|
214
220
|
});
|
|
215
221
|
|
|
222
|
+
// Register plugins (synchronous part)
|
|
223
|
+
const pluginContext = { app, nunjucksEnv, options };
|
|
224
|
+
pluginManager.register(plugins, pluginContext);
|
|
225
|
+
|
|
216
226
|
// Request logging middleware
|
|
217
227
|
if (logging) {
|
|
218
228
|
app.use((req, res, next) => {
|
|
@@ -229,13 +239,37 @@ function createApp(options = {}) {
|
|
|
229
239
|
if (!isTest) {
|
|
230
240
|
console.log('\nMounting routes:');
|
|
231
241
|
}
|
|
232
|
-
mountPages(app, {
|
|
242
|
+
const routeMetadata = mountPages(app, {
|
|
233
243
|
pagesDir,
|
|
234
244
|
nunjucks: nunjucksEnv,
|
|
235
245
|
middlewares,
|
|
246
|
+
pluginManager,
|
|
236
247
|
silent: isTest
|
|
237
248
|
});
|
|
238
249
|
|
|
250
|
+
// Set route metadata in plugin manager
|
|
251
|
+
pluginManager.setRoutes(routeMetadata);
|
|
252
|
+
|
|
253
|
+
// Call onRoutesReady hook synchronously (plugins should not be async in this phase)
|
|
254
|
+
// and mount any custom routes added by plugins
|
|
255
|
+
for (const [name, plugin] of pluginManager.plugins) {
|
|
256
|
+
if (typeof plugin.onRoutesReady === 'function') {
|
|
257
|
+
const ctx = {
|
|
258
|
+
app,
|
|
259
|
+
nunjucksEnv,
|
|
260
|
+
options,
|
|
261
|
+
routes: pluginManager.routes,
|
|
262
|
+
usePlugin: (n) => pluginManager.getPluginAPI(n),
|
|
263
|
+
addHelper: (n, fn) => pluginManager.registeredHelpers.set(n, fn),
|
|
264
|
+
addFilter: (n, fn) => pluginManager.registeredFilters.set(n, fn),
|
|
265
|
+
addRoute: (method, path, ...handlers) => {
|
|
266
|
+
app[method.toLowerCase()](path, ...handlers);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
plugin.onRoutesReady(ctx);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
239
273
|
// 404 handler
|
|
240
274
|
app.use((req, res) => {
|
|
241
275
|
res.status(404);
|
|
@@ -302,7 +336,7 @@ function createApp(options = {}) {
|
|
|
302
336
|
}
|
|
303
337
|
});
|
|
304
338
|
|
|
305
|
-
return { app, nunjucksEnv };
|
|
339
|
+
return { app, nunjucksEnv, pluginManager };
|
|
306
340
|
}
|
|
307
341
|
|
|
308
342
|
// Export for use as library
|