webspresso 0.0.0

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/index.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Webspresso - Minimal file-based SSR framework for Node.js
3
+ */
4
+
5
+ const { createApp } = require('./src/server');
6
+ const {
7
+ mountPages,
8
+ filePathToRoute,
9
+ extractMethodFromFilename,
10
+ scanDirectory,
11
+ loadI18n,
12
+ createTranslator,
13
+ detectLocale
14
+ } = require('./src/file-router');
15
+ const { createHelpers, utils } = require('./src/helpers');
16
+
17
+ module.exports = {
18
+ // Main API
19
+ createApp,
20
+
21
+ // Router utilities (for advanced use)
22
+ mountPages,
23
+ filePathToRoute,
24
+ extractMethodFromFilename,
25
+ scanDirectory,
26
+ loadI18n,
27
+ createTranslator,
28
+ detectLocale,
29
+
30
+ // Template helpers
31
+ createHelpers,
32
+ utils
33
+ };
34
+
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "webspresso",
3
+ "version": "0.0.0",
4
+ "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "webspresso": "./bin/webspresso.js"
8
+ },
9
+ "scripts": {
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "test:coverage": "vitest run --coverage",
13
+ "release": "release-it"
14
+ },
15
+ "keywords": [
16
+ "ssr",
17
+ "nunjucks",
18
+ "express",
19
+ "file-based-routing",
20
+ "i18n",
21
+ "framework"
22
+ ],
23
+ "author": "cond",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/everytools/webspresso.git"
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "bin/",
32
+ "src/"
33
+ ],
34
+ "dependencies": {
35
+ "commander": "^11.1.0",
36
+ "express": "^4.18.2",
37
+ "inquirer": "^8.2.6",
38
+ "nunjucks": "^3.2.4"
39
+ },
40
+ "peerDependencies": {
41
+ "dotenv": "^16.0.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "dotenv": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@vitest/coverage-v8": "^1.2.0",
50
+ "chokidar": "^3.5.3",
51
+ "dotenv": "^16.3.1",
52
+ "release-it": "^17.11.0",
53
+ "supertest": "^6.3.4",
54
+ "vitest": "^1.2.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=18.0.0"
58
+ }
59
+ }
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Webspresso File-Based Router
3
+ * Scans pages/ directory and registers routes automatically
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { createHelpers } = require('./helpers');
9
+
10
+ // Cache for i18n files (key: filePath, value: { mtime, data })
11
+ const i18nCache = new Map();
12
+
13
+ // Cache for route configs in production
14
+ const configCache = new Map();
15
+
16
+ /**
17
+ * Convert a file path to an Express route pattern
18
+ * @param {string} filePath - Relative path from pages/
19
+ * @param {string} ext - File extension (.njk or .js)
20
+ * @returns {string} Express route pattern
21
+ */
22
+ function filePathToRoute(filePath, ext) {
23
+ // Remove extension
24
+ let route = filePath.replace(ext, '');
25
+
26
+ // Normalize path separators to forward slashes (handle both / and \)
27
+ route = route.split(path.sep).join('/');
28
+ route = route.split('\\').join('/'); // Also handle literal backslashes
29
+
30
+ // Handle index files
31
+ if (route.endsWith('/index')) {
32
+ route = route.slice(0, -6) || '/';
33
+ } else if (route === 'index') {
34
+ route = '/';
35
+ }
36
+
37
+ // Convert [param] to :param
38
+ route = route.replace(/\[([^\]\.]+)\]/g, ':$1');
39
+
40
+ // Convert [...param] to * (catch-all)
41
+ route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
42
+
43
+ // Ensure leading slash
44
+ if (!route.startsWith('/')) {
45
+ route = '/' + route;
46
+ }
47
+
48
+ return route;
49
+ }
50
+
51
+ /**
52
+ * Extract HTTP method from API filename
53
+ * @param {string} filename - Filename like health.get.js
54
+ * @returns {{ method: string, baseName: string }}
55
+ */
56
+ function extractMethodFromFilename(filename) {
57
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
58
+ const parts = filename.replace('.js', '').split('.');
59
+
60
+ if (parts.length > 1) {
61
+ const lastPart = parts[parts.length - 1].toLowerCase();
62
+ if (methods.includes(lastPart)) {
63
+ return {
64
+ method: lastPart,
65
+ baseName: parts.slice(0, -1).join('.')
66
+ };
67
+ }
68
+ }
69
+
70
+ return { method: 'get', baseName: parts.join('.') };
71
+ }
72
+
73
+ /**
74
+ * Recursively scan a directory for files
75
+ * @param {string} dir - Directory to scan
76
+ * @param {string} baseDir - Base directory for relative paths
77
+ * @returns {string[]} Array of relative file paths
78
+ */
79
+ function scanDirectory(dir, baseDir = dir) {
80
+ const files = [];
81
+
82
+ if (!fs.existsSync(dir)) {
83
+ return files;
84
+ }
85
+
86
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
87
+
88
+ for (const entry of entries) {
89
+ const fullPath = path.join(dir, entry.name);
90
+ const relativePath = path.relative(baseDir, fullPath);
91
+
92
+ if (entry.isDirectory()) {
93
+ // Skip locales directories
94
+ if (entry.name === 'locales') continue;
95
+ files.push(...scanDirectory(fullPath, baseDir));
96
+ } else if (entry.isFile()) {
97
+ // Skip files starting with _
98
+ if (entry.name.startsWith('_')) continue;
99
+ files.push(relativePath);
100
+ }
101
+ }
102
+
103
+ return files;
104
+ }
105
+
106
+ /**
107
+ * Load i18n JSON file with caching
108
+ * @param {string} filePath - Path to JSON file
109
+ * @returns {Object} Parsed JSON or empty object
110
+ */
111
+ function loadI18nFile(filePath) {
112
+ if (!fs.existsSync(filePath)) {
113
+ return {};
114
+ }
115
+
116
+ try {
117
+ const stats = fs.statSync(filePath);
118
+ const cached = i18nCache.get(filePath);
119
+
120
+ if (cached && cached.mtime >= stats.mtimeMs) {
121
+ return cached.data;
122
+ }
123
+
124
+ const content = fs.readFileSync(filePath, 'utf-8');
125
+ const data = JSON.parse(content);
126
+
127
+ i18nCache.set(filePath, { mtime: stats.mtimeMs, data });
128
+ return data;
129
+ } catch (err) {
130
+ console.error(`Error loading i18n file ${filePath}:`, err.message);
131
+ return {};
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Load merged i18n translations for a route
137
+ * @param {string} pagesDir - Pages directory path
138
+ * @param {string} routeDir - Route directory path
139
+ * @param {string} locale - Locale code
140
+ * @returns {Object} Merged translations
141
+ */
142
+ function loadI18n(pagesDir, routeDir, locale) {
143
+ // Load global translations
144
+ const globalPath = path.join(pagesDir, 'locales', `${locale}.json`);
145
+ const globalTranslations = loadI18nFile(globalPath);
146
+
147
+ // Load route-specific translations
148
+ const routePath = path.join(routeDir, 'locales', `${locale}.json`);
149
+ const routeTranslations = loadI18nFile(routePath);
150
+
151
+ // Merge: route-specific overrides global
152
+ return { ...globalTranslations, ...routeTranslations };
153
+ }
154
+
155
+ /**
156
+ * Create a translation function
157
+ * @param {Object} translations - Translation object
158
+ * @returns {Function} Translation function t(key)
159
+ */
160
+ function createTranslator(translations) {
161
+ return function t(key, params = {}) {
162
+ let value = translations[key];
163
+
164
+ if (value === undefined) {
165
+ // Try nested key lookup (e.g., "meta.title")
166
+ const parts = key.split('.');
167
+ value = translations;
168
+ for (const part of parts) {
169
+ if (value && typeof value === 'object') {
170
+ value = value[part];
171
+ } else {
172
+ value = undefined;
173
+ break;
174
+ }
175
+ }
176
+ }
177
+
178
+ if (value === undefined) {
179
+ return key; // Return key if translation not found
180
+ }
181
+
182
+ // Replace params like {{name}} in the translation
183
+ if (typeof value === 'string' && Object.keys(params).length > 0) {
184
+ for (const [paramKey, paramValue] of Object.entries(params)) {
185
+ value = value.replace(new RegExp(`{{\\s*${paramKey}\\s*}}`, 'g'), paramValue);
186
+ }
187
+ }
188
+
189
+ return value;
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Load route config module
195
+ * @param {string} configPath - Path to config .js file
196
+ * @param {boolean} isDev - Is development mode
197
+ * @returns {Object|null} Route config or null
198
+ */
199
+ function loadRouteConfig(configPath, isDev) {
200
+ if (!fs.existsSync(configPath)) {
201
+ return null;
202
+ }
203
+
204
+ try {
205
+ // Clear cache in development mode
206
+ if (isDev && require.cache[require.resolve(configPath)]) {
207
+ delete require.cache[require.resolve(configPath)];
208
+ }
209
+
210
+ if (!isDev && configCache.has(configPath)) {
211
+ return configCache.get(configPath);
212
+ }
213
+
214
+ const config = require(configPath);
215
+
216
+ if (!isDev) {
217
+ configCache.set(configPath, config);
218
+ }
219
+
220
+ return config;
221
+ } catch (err) {
222
+ console.error(`Error loading route config ${configPath}:`, err.message);
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Load global hooks
229
+ * @param {string} pagesDir - Pages directory path
230
+ * @param {boolean} isDev - Is development mode
231
+ * @returns {Object|null} Global hooks or null
232
+ */
233
+ function loadGlobalHooks(pagesDir, isDev) {
234
+ const hooksPath = path.join(pagesDir, '_hooks.js');
235
+ return loadRouteConfig(hooksPath, isDev);
236
+ }
237
+
238
+ /**
239
+ * Execute a hook if it exists
240
+ * @param {Object} hooks - Hooks object
241
+ * @param {string} hookName - Hook name
242
+ * @param {Object} ctx - Context object
243
+ */
244
+ async function executeHook(hooks, hookName, ctx) {
245
+ if (hooks && typeof hooks[hookName] === 'function') {
246
+ await hooks[hookName](ctx);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Detect locale from request
252
+ * @param {Object} req - Express request
253
+ * @returns {string} Locale code
254
+ */
255
+ function detectLocale(req) {
256
+ // 1. Check query parameter
257
+ if (req.query.lang) {
258
+ return req.query.lang;
259
+ }
260
+
261
+ // 2. Check Accept-Language header
262
+ const acceptLanguage = req.get('Accept-Language');
263
+ if (acceptLanguage) {
264
+ const supported = (process.env.SUPPORTED_LOCALES || 'en').split(',');
265
+ const preferred = acceptLanguage.split(',')[0].split('-')[0].toLowerCase();
266
+ if (supported.includes(preferred)) {
267
+ return preferred;
268
+ }
269
+ }
270
+
271
+ // 3. Default locale
272
+ return process.env.DEFAULT_LOCALE || 'en';
273
+ }
274
+
275
+ /**
276
+ * Mount all pages as routes on the Express app
277
+ * @param {Object} app - Express app
278
+ * @param {Object} options - Options
279
+ * @param {string} options.pagesDir - Pages directory path
280
+ * @param {Object} options.nunjucks - Nunjucks environment
281
+ * @param {boolean} options.silent - Suppress console output
282
+ */
283
+ function mountPages(app, options) {
284
+ const { pagesDir, nunjucks, silent = false } = options;
285
+ const isDev = process.env.NODE_ENV !== 'production';
286
+ const log = silent ? () => {} : console.log.bind(console);
287
+
288
+ // Get absolute path to pages directory
289
+ const absolutePagesDir = path.resolve(pagesDir);
290
+
291
+ // Load global hooks
292
+ const globalHooks = loadGlobalHooks(absolutePagesDir, isDev);
293
+
294
+ // Scan for files
295
+ const files = scanDirectory(absolutePagesDir);
296
+
297
+ // Separate SSR pages and API routes
298
+ const ssrRoutes = [];
299
+ const apiRoutes = [];
300
+
301
+ for (const file of files) {
302
+ const ext = path.extname(file);
303
+ const isApi = file.startsWith('api' + path.sep) || file.startsWith('api/');
304
+
305
+ if (isApi && ext === '.js') {
306
+ // API route
307
+ const { method, baseName } = extractMethodFromFilename(path.basename(file));
308
+ const dirPart = path.dirname(file);
309
+ const routePath = dirPart === '.'
310
+ ? `/${baseName}`
311
+ : `/${dirPart}/${baseName}`.split(path.sep).join('/');
312
+
313
+ apiRoutes.push({
314
+ file,
315
+ method,
316
+ routePath: filePathToRoute(routePath.replace(/^\/api/, '/api'), ''),
317
+ fullPath: path.join(absolutePagesDir, file)
318
+ });
319
+ } else if (ext === '.njk') {
320
+ // SSR page
321
+ const routePath = filePathToRoute(file, '.njk');
322
+ const configPath = path.join(absolutePagesDir, file.replace('.njk', '.js'));
323
+ const routeDir = path.dirname(path.join(absolutePagesDir, file));
324
+
325
+ ssrRoutes.push({
326
+ file,
327
+ routePath,
328
+ fullPath: path.join(absolutePagesDir, file),
329
+ configPath,
330
+ routeDir
331
+ });
332
+ }
333
+ }
334
+
335
+ // Sort routes: specific routes first, then dynamic, then catch-all
336
+ const sortRoutes = (routes) => {
337
+ return routes.sort((a, b) => {
338
+ const aHasCatchAll = a.routePath.includes('*');
339
+ const bHasCatchAll = b.routePath.includes('*');
340
+ const aHasDynamic = a.routePath.includes(':');
341
+ const bHasDynamic = b.routePath.includes(':');
342
+
343
+ if (aHasCatchAll && !bHasCatchAll) return 1;
344
+ if (!aHasCatchAll && bHasCatchAll) return -1;
345
+ if (aHasDynamic && !bHasDynamic) return 1;
346
+ if (!aHasDynamic && bHasDynamic) return -1;
347
+
348
+ return a.routePath.localeCompare(b.routePath);
349
+ });
350
+ };
351
+
352
+ // Register API routes
353
+ for (const route of sortRoutes(apiRoutes)) {
354
+ const handler = require(route.fullPath);
355
+ const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
356
+
357
+ if (typeof handlerFn !== 'function') {
358
+ console.warn(`API route ${route.file} does not export a function`);
359
+ continue;
360
+ }
361
+
362
+ app[route.method](route.routePath, async (req, res, next) => {
363
+ try {
364
+ // Reload handler in dev mode
365
+ if (isDev && require.cache[require.resolve(route.fullPath)]) {
366
+ delete require.cache[require.resolve(route.fullPath)];
367
+ }
368
+ const currentHandler = isDev
369
+ ? require(route.fullPath)
370
+ : handler;
371
+ const fn = typeof currentHandler === 'function'
372
+ ? currentHandler
373
+ : currentHandler.default || currentHandler.handler;
374
+
375
+ await fn(req, res, next);
376
+ } catch (err) {
377
+ console.error(`API error ${route.routePath}:`, err);
378
+ res.status(500).json({ error: 'Internal Server Error' });
379
+ }
380
+ });
381
+
382
+ log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
383
+ }
384
+
385
+ // Register SSR routes
386
+ for (const route of sortRoutes(ssrRoutes)) {
387
+ app.get(route.routePath, async (req, res, next) => {
388
+ try {
389
+ // Detect locale
390
+ const locale = detectLocale(req);
391
+
392
+ // Load translations
393
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
394
+ const t = createTranslator(translations);
395
+
396
+ // Load route config
397
+ const config = loadRouteConfig(route.configPath, isDev);
398
+ const routeHooks = config?.hooks || {};
399
+
400
+ // Create context
401
+ const ctx = {
402
+ req,
403
+ res,
404
+ path: route.routePath,
405
+ file: route.file,
406
+ routeDir: route.routeDir,
407
+ locale,
408
+ t,
409
+ data: {},
410
+ meta: {
411
+ title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
412
+ description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
413
+ indexable: true,
414
+ canonical: null
415
+ },
416
+ fsy: createHelpers({ req, res, locale })
417
+ };
418
+
419
+ // Execute hooks: onRequest
420
+ await executeHook(globalHooks, 'onRequest', ctx);
421
+ await executeHook(routeHooks, 'onRequest', ctx);
422
+
423
+ // Execute hooks: onRoute
424
+ await executeHook(globalHooks, 'onRoute', ctx);
425
+ await executeHook(routeHooks, 'onRoute', ctx);
426
+
427
+ // Execute hooks: beforeMiddleware
428
+ await executeHook(globalHooks, 'beforeMiddleware', ctx);
429
+ await executeHook(routeHooks, 'beforeMiddleware', ctx);
430
+
431
+ // Run route middleware
432
+ if (config?.middleware && Array.isArray(config.middleware)) {
433
+ for (const mw of config.middleware) {
434
+ await new Promise((resolve, reject) => {
435
+ mw(req, res, (err) => {
436
+ if (err) reject(err);
437
+ else resolve();
438
+ });
439
+ });
440
+ }
441
+ }
442
+
443
+ // Execute hooks: afterMiddleware
444
+ await executeHook(globalHooks, 'afterMiddleware', ctx);
445
+ await executeHook(routeHooks, 'afterMiddleware', ctx);
446
+
447
+ // Execute hooks: beforeLoad
448
+ await executeHook(globalHooks, 'beforeLoad', ctx);
449
+ await executeHook(routeHooks, 'beforeLoad', ctx);
450
+
451
+ // Run load function
452
+ if (config?.load && typeof config.load === 'function') {
453
+ const loadData = await config.load(req, ctx);
454
+ ctx.data = { ...ctx.data, ...loadData };
455
+ }
456
+
457
+ // Execute hooks: afterLoad
458
+ await executeHook(globalHooks, 'afterLoad', ctx);
459
+ await executeHook(routeHooks, 'afterLoad', ctx);
460
+
461
+ // Run meta function
462
+ if (config?.meta && typeof config.meta === 'function') {
463
+ const metaData = await config.meta(req, ctx);
464
+ ctx.meta = { ...ctx.meta, ...metaData };
465
+ }
466
+
467
+ // Execute hooks: beforeRender
468
+ await executeHook(globalHooks, 'beforeRender', ctx);
469
+ await executeHook(routeHooks, 'beforeRender', ctx);
470
+
471
+ // Render the template
472
+ const templatePath = route.file.split(path.sep).join('/');
473
+ const html = nunjucks.render(templatePath, {
474
+ ...ctx.data,
475
+ meta: ctx.meta,
476
+ locale: ctx.locale,
477
+ t: ctx.t,
478
+ fsy: ctx.fsy,
479
+ req: {
480
+ path: req.path,
481
+ query: req.query,
482
+ params: req.params
483
+ }
484
+ });
485
+
486
+ // Execute hooks: afterRender
487
+ ctx.html = html;
488
+ await executeHook(globalHooks, 'afterRender', ctx);
489
+ await executeHook(routeHooks, 'afterRender', ctx);
490
+
491
+ res.send(ctx.html);
492
+ } catch (err) {
493
+ console.error(`SSR error ${route.routePath}:`, err);
494
+
495
+ // Execute onError hook
496
+ const ctx = { req, res, error: err };
497
+ try {
498
+ await executeHook(globalHooks, 'onError', ctx, err);
499
+ } catch (hookErr) {
500
+ console.error('Error in onError hook:', hookErr);
501
+ }
502
+
503
+ res.status(500).send('Internal Server Error');
504
+ }
505
+ });
506
+
507
+ log(` GET ${route.routePath} -> ${route.file}`);
508
+ }
509
+ }
510
+
511
+ module.exports = {
512
+ mountPages,
513
+ filePathToRoute,
514
+ extractMethodFromFilename,
515
+ scanDirectory,
516
+ loadI18n,
517
+ createTranslator,
518
+ detectLocale
519
+ };
520
+