webspresso 0.0.2 → 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
 
@@ -184,13 +186,267 @@ Creates and configures the Express app.
184
186
  - `true` or `undefined`: Use default secure configuration
185
187
  - `false`: Disable Helmet
186
188
  - `Object`: Custom Helmet configuration (merged with defaults)
189
+ - `middlewares` (optional): Named middleware registry for routes
187
190
 
188
- **Default Helmet Configuration:**
189
- - CSP disabled in development, enabled in production
190
- - HSTS, XSS protection, frame guard, and other security headers enabled
191
- - Tailwind CSS inline styles allowed in CSP
191
+ **Example with middlewares:**
192
192
 
193
- **Returns:** `{ app, nunjucksEnv }`
193
+ ```javascript
194
+ const { createApp } = require('webspresso');
195
+
196
+ const { app } = createApp({
197
+ pagesDir: './pages',
198
+ viewsDir: './views',
199
+ middlewares: {
200
+ auth: (req, res, next) => {
201
+ if (!req.session?.user) {
202
+ return res.redirect('/login');
203
+ }
204
+ next();
205
+ },
206
+ admin: (req, res, next) => {
207
+ if (req.session?.user?.role !== 'admin') {
208
+ return res.status(403).send('Forbidden');
209
+ }
210
+ next();
211
+ },
212
+ rateLimit: require('express-rate-limit')({ windowMs: 60000, max: 100 })
213
+ }
214
+ });
215
+ ```
216
+
217
+ Then use in route configs by name:
218
+
219
+ ```javascript
220
+ // pages/admin/index.js
221
+ module.exports = {
222
+ middleware: ['auth', 'admin'], // Use named middlewares
223
+ load(req, ctx) { ... }
224
+ };
225
+
226
+ // pages/api/data.get.js
227
+ module.exports = {
228
+ middleware: ['auth', 'rateLimit'],
229
+ handler: (req, res) => res.json({ data: 'protected' })
230
+ };
231
+ ```
232
+
233
+ **Custom Error Pages:**
234
+
235
+ ```javascript
236
+ const { createApp } = require('webspresso');
237
+
238
+ const { app } = createApp({
239
+ pagesDir: './pages',
240
+ viewsDir: './views',
241
+ errorPages: {
242
+ // Option 1: Custom handler function
243
+ notFound: (req, res) => {
244
+ res.render('errors/404.njk', { url: req.url });
245
+ },
246
+
247
+ // Option 2: Template path (rendered with Nunjucks)
248
+ serverError: 'errors/500.njk'
249
+ }
250
+ });
251
+ ```
252
+
253
+ Error templates receive these variables:
254
+ - `404.njk`: `{ url, method }`
255
+ - `500.njk`: `{ error, status, isDev }`
256
+
257
+ **Asset Management:**
258
+
259
+ Configure asset handling with versioning and manifest support:
260
+
261
+ ```javascript
262
+ const { createApp } = require('webspresso');
263
+ const path = require('path');
264
+
265
+ const { app } = createApp({
266
+ pagesDir: './pages',
267
+ viewsDir: './views',
268
+ publicDir: './public',
269
+ assets: {
270
+ // Option 1: Simple versioning (cache busting)
271
+ version: '1.2.3', // or process.env.APP_VERSION
272
+
273
+ // Option 2: Manifest file (Vite, Webpack, etc.)
274
+ manifestPath: path.join(__dirname, 'public/.vite/manifest.json'),
275
+
276
+ // URL prefix for assets
277
+ prefix: '/static'
278
+ }
279
+ });
280
+ ```
281
+
282
+ Use asset helpers in templates:
283
+
284
+ ```njk
285
+ {# Using fsy helpers (auto-resolved) #}
286
+ <link rel="stylesheet" href="{{ fsy.asset('/css/style.css') }}">
287
+
288
+ {# Or generate full HTML tags #}
289
+ {{ fsy.css('/css/style.css') | safe }}
290
+ {{ fsy.js('/js/app.js', { defer: true, type: 'module' }) | safe }}
291
+ {{ fsy.img('/images/logo.png', 'Site Logo', { class: 'logo', loading: 'lazy' }) | safe }}
292
+ ```
293
+
294
+ Asset helpers available in `fsy`:
295
+ - `asset(path)` - Returns versioned/manifest-resolved URL
296
+ - `css(href, attrs)` - Generates `<link>` tag
297
+ - `js(src, attrs)` - Generates `<script>` tag
298
+ - `img(src, alt, attrs)` - Generates `<img>` tag
299
+
300
+ **Manifest Support:**
301
+
302
+ Works with Vite and Webpack manifest formats:
303
+
304
+ ```json
305
+ // Vite manifest format (.vite/manifest.json)
306
+ {
307
+ "css/style.css": { "file": "assets/style-abc123.css" },
308
+ "js/app.js": { "file": "assets/app-xyz789.js" }
309
+ }
310
+
311
+ // Webpack manifest format
312
+ {
313
+ "/css/style.css": "/dist/style.abc123.css",
314
+ "/js/app.js": "/dist/app.xyz789.js"
315
+ }
316
+ ```
317
+
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
+ ```
194
450
 
195
451
  ## File-Based Routing
196
452
 
package/index.js CHANGED
@@ -12,7 +12,19 @@ const {
12
12
  createTranslator,
13
13
  detectLocale
14
14
  } = require('./src/file-router');
15
- const { createHelpers, utils } = require('./src/helpers');
15
+ const {
16
+ createHelpers,
17
+ utils,
18
+ AssetManager,
19
+ configureAssets,
20
+ getAssetManager
21
+ } = require('./src/helpers');
22
+ const {
23
+ PluginManager,
24
+ createPluginManager,
25
+ getPluginManager,
26
+ resetPluginManager
27
+ } = require('./src/plugin-manager');
16
28
 
17
29
  module.exports = {
18
30
  // Main API
@@ -29,6 +41,17 @@ module.exports = {
29
41
 
30
42
  // Template helpers
31
43
  createHelpers,
32
- utils
44
+ utils,
45
+
46
+ // Asset management
47
+ AssetManager,
48
+ configureAssets,
49
+ getAssetManager,
50
+
51
+ // Plugin system
52
+ PluginManager,
53
+ createPluginManager,
54
+ getPluginManager,
55
+ resetPluginManager
33
56
  };
34
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.2",
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": {
@@ -272,16 +272,50 @@ function detectLocale(req) {
272
272
  return process.env.DEFAULT_LOCALE || 'en';
273
273
  }
274
274
 
275
+ /**
276
+ * Resolve middleware from config - supports both functions and named strings
277
+ * @param {Array} middlewareConfig - Array of middleware functions or names
278
+ * @param {Object} middlewareRegistry - Named middleware registry
279
+ * @returns {Array} Array of resolved middleware functions
280
+ */
281
+ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
282
+ if (!middlewareConfig || !Array.isArray(middlewareConfig)) {
283
+ return [];
284
+ }
285
+
286
+ return middlewareConfig.map((mw, index) => {
287
+ if (typeof mw === 'function') {
288
+ return mw;
289
+ }
290
+
291
+ if (typeof mw === 'string') {
292
+ const resolved = middlewareRegistry[mw];
293
+ if (!resolved) {
294
+ throw new Error(`Middleware "${mw}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
295
+ }
296
+ if (typeof resolved !== 'function') {
297
+ throw new Error(`Middleware "${mw}" must be a function`);
298
+ }
299
+ return resolved;
300
+ }
301
+
302
+ throw new Error(`Invalid middleware at index ${index}: must be a function or string name`);
303
+ });
304
+ }
305
+
275
306
  /**
276
307
  * Mount all pages as routes on the Express app
277
308
  * @param {Object} app - Express app
278
309
  * @param {Object} options - Options
279
310
  * @param {string} options.pagesDir - Pages directory path
280
311
  * @param {Object} options.nunjucks - Nunjucks environment
312
+ * @param {Object} options.middlewares - Named middleware registry
313
+ * @param {Object} options.pluginManager - Plugin manager instance
281
314
  * @param {boolean} options.silent - Suppress console output
315
+ * @returns {Array} Route metadata for plugins
282
316
  */
283
317
  function mountPages(app, options) {
284
- const { pagesDir, nunjucks, silent = false } = options;
318
+ const { pagesDir, nunjucks, middlewares = {}, pluginManager = null, silent = false } = options;
285
319
  const isDev = process.env.NODE_ENV !== 'production';
286
320
  const log = silent ? () => {} : console.log.bind(console);
287
321
 
@@ -353,6 +387,7 @@ function mountPages(app, options) {
353
387
  for (const route of sortRoutes(apiRoutes)) {
354
388
  const handler = require(route.fullPath);
355
389
  const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
390
+ const routeMiddleware = handler.middleware;
356
391
 
357
392
  if (typeof handlerFn !== 'function') {
358
393
  console.warn(`API route ${route.file} does not export a function`);
@@ -372,6 +407,20 @@ function mountPages(app, options) {
372
407
  ? currentHandler
373
408
  : currentHandler.default || currentHandler.handler;
374
409
 
410
+ // Run middleware if defined
411
+ const mwConfig = isDev ? currentHandler.middleware : routeMiddleware;
412
+ if (mwConfig) {
413
+ const resolvedMw = resolveMiddlewares(mwConfig, middlewares);
414
+ for (const mw of resolvedMw) {
415
+ await new Promise((resolve, reject) => {
416
+ mw(req, res, (err) => {
417
+ if (err) reject(err);
418
+ else resolve();
419
+ });
420
+ });
421
+ }
422
+ }
423
+
375
424
  await fn(req, res, next);
376
425
  } catch (err) {
377
426
  console.error(`API error ${route.routePath}:`, err);
@@ -397,7 +446,10 @@ function mountPages(app, options) {
397
446
  const config = loadRouteConfig(route.configPath, isDev);
398
447
  const routeHooks = config?.hooks || {};
399
448
 
400
- // Create context
449
+ // Create context with plugin helpers merged
450
+ const baseHelpers = createHelpers({ req, res, locale });
451
+ const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
452
+
401
453
  const ctx = {
402
454
  req,
403
455
  res,
@@ -413,7 +465,7 @@ function mountPages(app, options) {
413
465
  indexable: true,
414
466
  canonical: null
415
467
  },
416
- fsy: createHelpers({ req, res, locale })
468
+ fsy: { ...baseHelpers, ...pluginHelpers }
417
469
  };
418
470
 
419
471
  // Execute hooks: onRequest
@@ -429,8 +481,9 @@ function mountPages(app, options) {
429
481
  await executeHook(routeHooks, 'beforeMiddleware', ctx);
430
482
 
431
483
  // Run route middleware
432
- if (config?.middleware && Array.isArray(config.middleware)) {
433
- for (const mw of config.middleware) {
484
+ if (config?.middleware) {
485
+ const resolvedMiddlewares = resolveMiddlewares(config.middleware, middlewares);
486
+ for (const mw of resolvedMiddlewares) {
434
487
  await new Promise((resolve, reject) => {
435
488
  mw(req, res, (err) => {
436
489
  if (err) reject(err);
@@ -506,6 +559,26 @@ function mountPages(app, options) {
506
559
 
507
560
  log(` GET ${route.routePath} -> ${route.file}`);
508
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;
509
582
  }
510
583
 
511
584
  module.exports = {
@@ -515,6 +588,7 @@ module.exports = {
515
588
  scanDirectory,
516
589
  loadI18n,
517
590
  createTranslator,
518
- detectLocale
591
+ detectLocale,
592
+ resolveMiddlewares
519
593
  };
520
594
 
package/src/helpers.js CHANGED
@@ -4,6 +4,167 @@
4
4
  */
5
5
 
6
6
  const querystring = require('querystring');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Asset Manager - handles asset paths, versioning, and manifest
12
+ */
13
+ class AssetManager {
14
+ constructor(options = {}) {
15
+ this.publicDir = options.publicDir || 'public';
16
+ this.manifestPath = options.manifestPath || null;
17
+ this.manifest = null;
18
+ this.version = options.version || null;
19
+ this.prefix = options.prefix || '';
20
+
21
+ // Load manifest if path provided
22
+ if (this.manifestPath) {
23
+ this.loadManifest();
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Load asset manifest file (Vite, Webpack, etc.)
29
+ */
30
+ loadManifest() {
31
+ try {
32
+ const fullPath = path.resolve(this.manifestPath);
33
+ if (fs.existsSync(fullPath)) {
34
+ const content = fs.readFileSync(fullPath, 'utf-8');
35
+ this.manifest = JSON.parse(content);
36
+ }
37
+ } catch (err) {
38
+ console.warn('Failed to load asset manifest:', err.message);
39
+ this.manifest = null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolve asset path from manifest or add version
45
+ * @param {string} assetPath - Asset path
46
+ * @returns {string}
47
+ */
48
+ resolve(assetPath) {
49
+ // Remove leading slash for manifest lookup
50
+ const lookupPath = assetPath.replace(/^\//, '');
51
+
52
+ // Check manifest first
53
+ if (this.manifest) {
54
+ // Vite manifest format
55
+ if (this.manifest[lookupPath]) {
56
+ const entry = this.manifest[lookupPath];
57
+ const resolved = typeof entry === 'string' ? entry : entry.file;
58
+ return this.prefix + '/' + resolved;
59
+ }
60
+
61
+ // Webpack manifest format (direct mapping)
62
+ if (this.manifest[assetPath]) {
63
+ return this.prefix + this.manifest[assetPath];
64
+ }
65
+ }
66
+
67
+ // Add version query string if provided
68
+ let finalPath = assetPath.startsWith('/') ? assetPath : '/' + assetPath;
69
+ finalPath = this.prefix + finalPath;
70
+
71
+ if (this.version) {
72
+ const separator = finalPath.includes('?') ? '&' : '?';
73
+ finalPath += `${separator}v=${this.version}`;
74
+ }
75
+
76
+ return finalPath;
77
+ }
78
+
79
+ /**
80
+ * Get asset URL
81
+ */
82
+ asset(assetPath) {
83
+ return this.resolve(assetPath);
84
+ }
85
+
86
+ /**
87
+ * Generate CSS link tag
88
+ */
89
+ css(href, attributes = {}) {
90
+ const resolvedHref = this.resolve(href);
91
+ const attrs = this.buildAttributes({
92
+ rel: 'stylesheet',
93
+ href: resolvedHref,
94
+ ...attributes
95
+ });
96
+ return `<link ${attrs}>`;
97
+ }
98
+
99
+ /**
100
+ * Generate JS script tag
101
+ */
102
+ js(src, attributes = {}) {
103
+ const resolvedSrc = this.resolve(src);
104
+ const attrs = this.buildAttributes({
105
+ src: resolvedSrc,
106
+ ...attributes
107
+ });
108
+ return `<script ${attrs}></script>`;
109
+ }
110
+
111
+ /**
112
+ * Generate image tag
113
+ */
114
+ img(src, alt = '', attributes = {}) {
115
+ const resolvedSrc = this.resolve(src);
116
+ const attrs = this.buildAttributes({
117
+ src: resolvedSrc,
118
+ alt,
119
+ ...attributes
120
+ });
121
+ return `<img ${attrs}>`;
122
+ }
123
+
124
+ /**
125
+ * Build HTML attributes string
126
+ */
127
+ buildAttributes(attrs) {
128
+ return Object.entries(attrs)
129
+ .filter(([_, v]) => v !== undefined && v !== null && v !== false)
130
+ .map(([k, v]) => v === true ? k : `${k}="${this.escapeHtml(String(v))}"`)
131
+ .join(' ');
132
+ }
133
+
134
+ /**
135
+ * Escape HTML special characters
136
+ */
137
+ escapeHtml(str) {
138
+ return str
139
+ .replace(/&/g, '&amp;')
140
+ .replace(/"/g, '&quot;')
141
+ .replace(/'/g, '&#39;')
142
+ .replace(/</g, '&lt;')
143
+ .replace(/>/g, '&gt;');
144
+ }
145
+ }
146
+
147
+ // Global asset manager instance
148
+ let globalAssetManager = null;
149
+
150
+ /**
151
+ * Configure global asset manager
152
+ * @param {Object} options - Asset manager options
153
+ */
154
+ function configureAssets(options = {}) {
155
+ globalAssetManager = new AssetManager(options);
156
+ return globalAssetManager;
157
+ }
158
+
159
+ /**
160
+ * Get or create asset manager
161
+ */
162
+ function getAssetManager() {
163
+ if (!globalAssetManager) {
164
+ globalAssetManager = new AssetManager();
165
+ }
166
+ return globalAssetManager;
167
+ }
7
168
 
8
169
  /**
9
170
  * Create the fsy helper object bound to the current request context
@@ -217,6 +378,47 @@ function createHelpers(ctx) {
217
378
  return regex.test(currentPath);
218
379
  }
219
380
  return false;
381
+ },
382
+
383
+ // Asset helpers
384
+ /**
385
+ * Get asset URL with versioning/manifest support
386
+ * @param {string} assetPath - Asset path
387
+ * @returns {string}
388
+ */
389
+ asset(assetPath) {
390
+ return getAssetManager().asset(assetPath);
391
+ },
392
+
393
+ /**
394
+ * Generate CSS link tag
395
+ * @param {string} href - CSS file path
396
+ * @param {Object} attrs - Additional attributes
397
+ * @returns {string}
398
+ */
399
+ css(href, attrs = {}) {
400
+ return getAssetManager().css(href, attrs);
401
+ },
402
+
403
+ /**
404
+ * Generate JS script tag
405
+ * @param {string} src - JS file path
406
+ * @param {Object} attrs - Additional attributes
407
+ * @returns {string}
408
+ */
409
+ js(src, attrs = {}) {
410
+ return getAssetManager().js(src, attrs);
411
+ },
412
+
413
+ /**
414
+ * Generate image tag
415
+ * @param {string} src - Image file path
416
+ * @param {string} alt - Alt text
417
+ * @param {Object} attrs - Additional attributes
418
+ * @returns {string}
419
+ */
420
+ img(src, alt = '', attrs = {}) {
421
+ return getAssetManager().img(src, alt, attrs);
220
422
  }
221
423
  };
222
424
  }
@@ -270,6 +472,9 @@ const utils = {
270
472
 
271
473
  module.exports = {
272
474
  createHelpers,
273
- utils
475
+ utils,
476
+ AssetManager,
477
+ configureAssets,
478
+ getAssetManager
274
479
  };
275
480