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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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": {
@@ -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: createHelpers({ req, res, locale })
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